skrypt-ai 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/auth/index.d.ts +13 -3
  2. package/dist/auth/index.js +101 -9
  3. package/dist/auth/keychain.d.ts +5 -0
  4. package/dist/auth/keychain.js +82 -0
  5. package/dist/auth/notices.d.ts +3 -0
  6. package/dist/auth/notices.js +42 -0
  7. package/dist/autofix/index.d.ts +0 -4
  8. package/dist/autofix/index.js +10 -24
  9. package/dist/capture/browser.d.ts +11 -0
  10. package/dist/capture/browser.js +173 -0
  11. package/dist/capture/diff.d.ts +23 -0
  12. package/dist/capture/diff.js +52 -0
  13. package/dist/capture/index.d.ts +23 -0
  14. package/dist/capture/index.js +210 -0
  15. package/dist/capture/naming.d.ts +17 -0
  16. package/dist/capture/naming.js +45 -0
  17. package/dist/capture/parser.d.ts +15 -0
  18. package/dist/capture/parser.js +80 -0
  19. package/dist/capture/types.d.ts +57 -0
  20. package/dist/capture/types.js +1 -0
  21. package/dist/cli.js +20 -3
  22. package/dist/commands/autofix.js +136 -120
  23. package/dist/commands/cron.js +58 -47
  24. package/dist/commands/deploy.js +123 -102
  25. package/dist/commands/generate.js +125 -7
  26. package/dist/commands/heal.d.ts +10 -0
  27. package/dist/commands/heal.js +201 -0
  28. package/dist/commands/i18n.js +146 -111
  29. package/dist/commands/import.d.ts +2 -0
  30. package/dist/commands/import.js +157 -0
  31. package/dist/commands/init.js +19 -7
  32. package/dist/commands/lint.js +50 -44
  33. package/dist/commands/llms-txt.js +59 -49
  34. package/dist/commands/login.js +63 -34
  35. package/dist/commands/mcp.js +6 -0
  36. package/dist/commands/monitor.js +13 -8
  37. package/dist/commands/qa.d.ts +2 -0
  38. package/dist/commands/qa.js +43 -0
  39. package/dist/commands/review-pr.js +108 -92
  40. package/dist/commands/sdk.js +128 -122
  41. package/dist/commands/security.d.ts +2 -0
  42. package/dist/commands/security.js +109 -0
  43. package/dist/commands/test.js +91 -92
  44. package/dist/commands/version.js +104 -75
  45. package/dist/commands/watch.js +130 -114
  46. package/dist/config/types.js +2 -2
  47. package/dist/context-hub/index.d.ts +23 -0
  48. package/dist/context-hub/index.js +179 -0
  49. package/dist/context-hub/mappings.d.ts +8 -0
  50. package/dist/context-hub/mappings.js +55 -0
  51. package/dist/context-hub/types.d.ts +33 -0
  52. package/dist/context-hub/types.js +1 -0
  53. package/dist/generator/generator.js +39 -6
  54. package/dist/generator/types.d.ts +7 -0
  55. package/dist/generator/writer.d.ts +3 -1
  56. package/dist/generator/writer.js +36 -7
  57. package/dist/importers/confluence.d.ts +5 -0
  58. package/dist/importers/confluence.js +137 -0
  59. package/dist/importers/detect.d.ts +20 -0
  60. package/dist/importers/detect.js +121 -0
  61. package/dist/importers/docusaurus.d.ts +5 -0
  62. package/dist/importers/docusaurus.js +279 -0
  63. package/dist/importers/gitbook.d.ts +5 -0
  64. package/dist/importers/gitbook.js +189 -0
  65. package/dist/importers/github.d.ts +8 -0
  66. package/dist/importers/github.js +99 -0
  67. package/dist/importers/index.d.ts +15 -0
  68. package/dist/importers/index.js +30 -0
  69. package/dist/importers/markdown.d.ts +6 -0
  70. package/dist/importers/markdown.js +105 -0
  71. package/dist/importers/mintlify.d.ts +5 -0
  72. package/dist/importers/mintlify.js +172 -0
  73. package/dist/importers/notion.d.ts +5 -0
  74. package/dist/importers/notion.js +174 -0
  75. package/dist/importers/readme.d.ts +5 -0
  76. package/dist/importers/readme.js +184 -0
  77. package/dist/importers/transform.d.ts +90 -0
  78. package/dist/importers/transform.js +457 -0
  79. package/dist/importers/types.d.ts +37 -0
  80. package/dist/importers/types.js +1 -0
  81. package/dist/llm/anthropic-client.d.ts +1 -0
  82. package/dist/llm/anthropic-client.js +3 -1
  83. package/dist/llm/index.d.ts +6 -4
  84. package/dist/llm/index.js +76 -261
  85. package/dist/llm/openai-client.d.ts +1 -0
  86. package/dist/llm/openai-client.js +7 -2
  87. package/dist/plugins/index.js +7 -0
  88. package/dist/qa/checks.d.ts +10 -0
  89. package/dist/qa/checks.js +492 -0
  90. package/dist/qa/fixes.d.ts +30 -0
  91. package/dist/qa/fixes.js +277 -0
  92. package/dist/qa/index.d.ts +29 -0
  93. package/dist/qa/index.js +187 -0
  94. package/dist/qa/types.d.ts +24 -0
  95. package/dist/qa/types.js +1 -0
  96. package/dist/scanner/csharp.d.ts +23 -0
  97. package/dist/scanner/csharp.js +421 -0
  98. package/dist/scanner/index.js +53 -26
  99. package/dist/scanner/java.d.ts +39 -0
  100. package/dist/scanner/java.js +318 -0
  101. package/dist/scanner/kotlin.d.ts +23 -0
  102. package/dist/scanner/kotlin.js +389 -0
  103. package/dist/scanner/php.d.ts +57 -0
  104. package/dist/scanner/php.js +351 -0
  105. package/dist/scanner/python.js +17 -0
  106. package/dist/scanner/ruby.d.ts +36 -0
  107. package/dist/scanner/ruby.js +431 -0
  108. package/dist/scanner/swift.d.ts +25 -0
  109. package/dist/scanner/swift.js +392 -0
  110. package/dist/scanner/types.d.ts +1 -1
  111. package/dist/template/content/docs/_navigation.json +46 -0
  112. package/dist/template/content/docs/_sidebars.json +684 -0
  113. package/dist/template/content/docs/core.md +4544 -0
  114. package/dist/template/content/docs/index.mdx +89 -0
  115. package/dist/template/content/docs/integrations.md +1158 -0
  116. package/dist/template/content/docs/llms-full.md +403 -0
  117. package/dist/template/content/docs/llms.txt +4588 -0
  118. package/dist/template/content/docs/other.md +10379 -0
  119. package/dist/template/content/docs/tools.md +746 -0
  120. package/dist/template/content/docs/types.md +531 -0
  121. package/dist/template/docs.json +13 -11
  122. package/dist/template/mdx-components.tsx +27 -2
  123. package/dist/template/package.json +6 -0
  124. package/dist/template/public/search-index.json +1 -1
  125. package/dist/template/scripts/build-search-index.mjs +149 -13
  126. package/dist/template/src/app/api/chat/route.ts +83 -128
  127. package/dist/template/src/app/docs/[...slug]/page.tsx +75 -20
  128. package/dist/template/src/app/docs/llms-full.md +151 -4
  129. package/dist/template/src/app/docs/llms.txt +2464 -847
  130. package/dist/template/src/app/docs/page.mdx +48 -38
  131. package/dist/template/src/app/layout.tsx +3 -1
  132. package/dist/template/src/app/page.tsx +22 -8
  133. package/dist/template/src/components/ai-chat.tsx +73 -64
  134. package/dist/template/src/components/breadcrumbs.tsx +21 -23
  135. package/dist/template/src/components/copy-button.tsx +13 -9
  136. package/dist/template/src/components/copy-page-button.tsx +54 -0
  137. package/dist/template/src/components/docs-layout.tsx +37 -25
  138. package/dist/template/src/components/header.tsx +51 -10
  139. package/dist/template/src/components/mdx/card.tsx +17 -3
  140. package/dist/template/src/components/mdx/code-block.tsx +13 -9
  141. package/dist/template/src/components/mdx/code-group.tsx +13 -8
  142. package/dist/template/src/components/mdx/heading.tsx +15 -2
  143. package/dist/template/src/components/mdx/highlighted-code.tsx +13 -8
  144. package/dist/template/src/components/mdx/index.tsx +2 -0
  145. package/dist/template/src/components/mdx/mermaid.tsx +110 -0
  146. package/dist/template/src/components/mdx/screenshot.tsx +150 -0
  147. package/dist/template/src/components/scroll-to-hash.tsx +48 -0
  148. package/dist/template/src/components/sidebar.tsx +12 -18
  149. package/dist/template/src/components/table-of-contents.tsx +9 -0
  150. package/dist/template/src/lib/highlight.ts +3 -88
  151. package/dist/template/src/lib/navigation.ts +159 -0
  152. package/dist/template/src/lib/search-types.ts +4 -1
  153. package/dist/template/src/lib/search.ts +30 -7
  154. package/dist/template/src/styles/globals.css +17 -6
  155. package/dist/utils/files.d.ts +9 -1
  156. package/dist/utils/files.js +59 -10
  157. package/dist/utils/validation.d.ts +0 -3
  158. package/dist/utils/validation.js +0 -26
  159. package/package.json +5 -1
@@ -1,4 +1,4 @@
1
- interface AuthConfig {
1
+ export interface AuthConfig {
2
2
  apiKey?: string;
3
3
  email?: string;
4
4
  plan?: 'free' | 'pro';
@@ -11,9 +11,19 @@ interface PlanCheckResponse {
11
11
  expiresAt?: string;
12
12
  error?: string;
13
13
  }
14
+ /**
15
+ * Sync auth config reader — checks env var and auth file only (no keychain).
16
+ * Use getAuthConfigAsync() when keychain access is needed.
17
+ */
14
18
  export declare function getAuthConfig(): AuthConfig;
15
- export declare function saveAuthConfig(config: AuthConfig): void;
16
- export declare function clearAuth(): void;
19
+ /**
20
+ * Async auth config reader — checks env var → keychain → auth file.
21
+ * This is the preferred method for all command actions.
22
+ */
23
+ export declare function getAuthConfigAsync(): Promise<AuthConfig>;
24
+ export declare function saveAuthConfig(config: AuthConfig): Promise<void>;
25
+ export declare function clearAuth(): Promise<void>;
26
+ export declare function getKeyStorageMethod(): Promise<'keychain' | 'file' | 'env' | 'none'>;
17
27
  export declare function checkPlan(apiKey: string): Promise<PlanCheckResponse>;
18
28
  export declare function requirePro(commandName: string): Promise<boolean>;
19
29
  export declare const PRO_COMMANDS: string[];
@@ -1,10 +1,57 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
- const CONFIG_DIR = join(homedir(), '.skrypt');
4
+ import { keychainStore, keychainRetrieve, keychainDelete } from './keychain.js';
5
+ const homeDir = homedir();
6
+ if (!homeDir) {
7
+ throw new Error('Could not determine home directory');
8
+ }
9
+ const CONFIG_DIR = join(homeDir, '.skrypt');
5
10
  const AUTH_FILE = join(CONFIG_DIR, 'auth.json');
6
11
  const API_BASE = process.env.SKRYPT_API_URL || 'https://api.skrypt.sh';
12
+ /**
13
+ * Sync auth config reader — checks env var and auth file only (no keychain).
14
+ * Use getAuthConfigAsync() when keychain access is needed.
15
+ */
7
16
  export function getAuthConfig() {
17
+ if (process.env.SKRYPT_API_KEY) {
18
+ const fileMeta = readAuthFile();
19
+ return {
20
+ apiKey: process.env.SKRYPT_API_KEY,
21
+ email: fileMeta.email,
22
+ plan: fileMeta.plan,
23
+ expiresAt: fileMeta.expiresAt,
24
+ };
25
+ }
26
+ return readAuthFile();
27
+ }
28
+ /**
29
+ * Async auth config reader — checks env var → keychain → auth file.
30
+ * This is the preferred method for all command actions.
31
+ */
32
+ export async function getAuthConfigAsync() {
33
+ if (process.env.SKRYPT_API_KEY) {
34
+ const fileMeta = readAuthFile();
35
+ return {
36
+ apiKey: process.env.SKRYPT_API_KEY,
37
+ email: fileMeta.email,
38
+ plan: fileMeta.plan,
39
+ expiresAt: fileMeta.expiresAt,
40
+ };
41
+ }
42
+ const keychainKey = await keychainRetrieve();
43
+ if (keychainKey) {
44
+ const fileMeta = readAuthFile();
45
+ return {
46
+ apiKey: keychainKey,
47
+ email: fileMeta.email,
48
+ plan: fileMeta.plan,
49
+ expiresAt: fileMeta.expiresAt,
50
+ };
51
+ }
52
+ return readAuthFile();
53
+ }
54
+ function readAuthFile() {
8
55
  if (!existsSync(AUTH_FILE)) {
9
56
  return {};
10
57
  }
@@ -15,16 +62,49 @@ export function getAuthConfig() {
15
62
  return {};
16
63
  }
17
64
  }
18
- export function saveAuthConfig(config) {
65
+ export async function saveAuthConfig(config) {
19
66
  mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
20
- writeFileSync(AUTH_FILE, JSON.stringify(config, null, 2));
67
+ let keyInKeychain = false;
68
+ if (config.apiKey) {
69
+ keyInKeychain = await keychainStore(config.apiKey);
70
+ }
71
+ // Write metadata to file (omit key if stored in keychain)
72
+ const fileConfig = {
73
+ email: config.email,
74
+ plan: config.plan,
75
+ expiresAt: config.expiresAt,
76
+ };
77
+ if (!keyInKeychain && config.apiKey) {
78
+ fileConfig.apiKey = config.apiKey;
79
+ }
80
+ // Write with restrictive permissions from the start
81
+ const content = JSON.stringify(fileConfig, null, 2);
82
+ writeFileSync(AUTH_FILE, content, { mode: 0o600 });
21
83
  chmodSync(AUTH_FILE, 0o600);
22
84
  }
23
- export function clearAuth() {
85
+ export async function clearAuth() {
86
+ await keychainDelete();
24
87
  if (existsSync(AUTH_FILE)) {
25
- writeFileSync(AUTH_FILE, '{}');
88
+ try {
89
+ unlinkSync(AUTH_FILE);
90
+ }
91
+ catch {
92
+ // Fallback: overwrite with empty
93
+ writeFileSync(AUTH_FILE, '{}', { mode: 0o600 });
94
+ }
26
95
  }
27
96
  }
97
+ export async function getKeyStorageMethod() {
98
+ if (process.env.SKRYPT_API_KEY)
99
+ return 'env';
100
+ const keychainKey = await keychainRetrieve();
101
+ if (keychainKey)
102
+ return 'keychain';
103
+ const fileConfig = readAuthFile();
104
+ if (fileConfig.apiKey)
105
+ return 'file';
106
+ return 'none';
107
+ }
28
108
  export async function checkPlan(apiKey) {
29
109
  try {
30
110
  const response = await fetch(`${API_BASE}/v1/plan`, {
@@ -36,14 +116,26 @@ export async function checkPlan(apiKey) {
36
116
  if (!response.ok) {
37
117
  return { valid: false, plan: 'free', email: '', error: 'Invalid API key' };
38
118
  }
39
- return await response.json();
119
+ const data = await response.json();
120
+ // Validate response shape
121
+ if (typeof data.valid !== 'boolean' || typeof data.email !== 'string') {
122
+ return { valid: false, plan: 'free', email: '', error: 'Invalid API response' };
123
+ }
124
+ const plan = data.plan === 'pro' ? 'pro' : 'free';
125
+ return {
126
+ valid: data.valid,
127
+ plan,
128
+ email: data.email,
129
+ expiresAt: typeof data.expiresAt === 'string' ? data.expiresAt : undefined,
130
+ error: typeof data.error === 'string' ? data.error : undefined,
131
+ };
40
132
  }
41
133
  catch {
42
134
  return { valid: false, plan: 'free', email: '', error: 'Failed to connect to API' };
43
135
  }
44
136
  }
45
137
  export async function requirePro(commandName) {
46
- const config = getAuthConfig();
138
+ const config = await getAuthConfigAsync();
47
139
  if (!config.apiKey) {
48
140
  console.error(`\n ⚡ ${commandName} requires Skrypt Pro\n`);
49
141
  console.error(' Get started:');
@@ -71,7 +163,7 @@ export async function requirePro(commandName) {
71
163
  return false;
72
164
  }
73
165
  // Cache the result for 1 hour
74
- saveAuthConfig({
166
+ await saveAuthConfig({
75
167
  ...config,
76
168
  plan: result.plan,
77
169
  email: result.email,
@@ -0,0 +1,5 @@
1
+ export declare function keychainAvailable(): Promise<boolean>;
2
+ export declare function keychainStore(key: string): Promise<boolean>;
3
+ export declare function keychainRetrieve(): Promise<string | null>;
4
+ export declare function keychainDelete(): Promise<boolean>;
5
+ export declare function getKeychainPlatformName(): string;
@@ -0,0 +1,82 @@
1
+ const SERVICE_NAME = 'skrypt';
2
+ const ACCOUNT_NAME = 'api-key';
3
+ let keyringLoadPromise = null;
4
+ async function loadKeyring() {
5
+ // Use a shared promise to prevent race conditions — only one import attempt
6
+ if (keyringLoadPromise)
7
+ return keyringLoadPromise;
8
+ keyringLoadPromise = doLoadKeyring();
9
+ return keyringLoadPromise;
10
+ }
11
+ async function doLoadKeyring() {
12
+ try {
13
+ // Dynamic import — @napi-rs/keyring is an optional dependency.
14
+ // Using indirect import to avoid TypeScript compile error for missing module.
15
+ const moduleName = '@napi-rs/keyring';
16
+ const mod = await import(/* webpackIgnore: true */ moduleName);
17
+ return mod;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export async function keychainAvailable() {
24
+ const mod = await loadKeyring();
25
+ if (!mod)
26
+ return false;
27
+ try {
28
+ const entry = new mod.Entry(SERVICE_NAME, ACCOUNT_NAME);
29
+ // Actually test that keychain is functional by attempting a read
30
+ entry.getPassword();
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ export async function keychainStore(key) {
38
+ const mod = await loadKeyring();
39
+ if (!mod)
40
+ return false;
41
+ try {
42
+ const entry = new mod.Entry(SERVICE_NAME, ACCOUNT_NAME);
43
+ entry.setPassword(key);
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ export async function keychainRetrieve() {
51
+ const mod = await loadKeyring();
52
+ if (!mod)
53
+ return null;
54
+ try {
55
+ const entry = new mod.Entry(SERVICE_NAME, ACCOUNT_NAME);
56
+ const password = entry.getPassword();
57
+ return password || null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ export async function keychainDelete() {
64
+ const mod = await loadKeyring();
65
+ if (!mod)
66
+ return false;
67
+ try {
68
+ const entry = new mod.Entry(SERVICE_NAME, ACCOUNT_NAME);
69
+ entry.deletePassword();
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ export function getKeychainPlatformName() {
77
+ switch (process.platform) {
78
+ case 'darwin': return 'macOS Keychain';
79
+ case 'win32': return 'Windows Credential Manager';
80
+ default: return 'system keyring (libsecret)';
81
+ }
82
+ }
@@ -0,0 +1,3 @@
1
+ export declare function hasSeenNotice(id: string): boolean;
2
+ export declare function markNoticeSeen(id: string): void;
3
+ export declare function showSecurityNotice(): void;
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ const CONFIG_DIR = join(homedir(), '.skrypt');
5
+ const NOTICES_FILE = join(CONFIG_DIR, 'notices.json');
6
+ function loadNotices() {
7
+ if (!existsSync(NOTICES_FILE))
8
+ return { seen: {} };
9
+ try {
10
+ return JSON.parse(readFileSync(NOTICES_FILE, 'utf-8'));
11
+ }
12
+ catch {
13
+ return { seen: {} };
14
+ }
15
+ }
16
+ function saveNotices(state) {
17
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
18
+ writeFileSync(NOTICES_FILE, JSON.stringify(state, null, 2));
19
+ }
20
+ export function hasSeenNotice(id) {
21
+ const state = loadNotices();
22
+ return id in state.seen;
23
+ }
24
+ export function markNoticeSeen(id) {
25
+ const state = loadNotices();
26
+ state.seen[id] = new Date().toISOString();
27
+ saveNotices(state);
28
+ }
29
+ export function showSecurityNotice() {
30
+ if (hasSeenNotice('security-v1'))
31
+ return;
32
+ console.log('');
33
+ console.log(' \x1b[36m🔒 Security Notice\x1b[0m');
34
+ console.log('');
35
+ console.log(' Your API keys are stored locally and never sent to Skrypt.');
36
+ console.log(' When using your own keys (OPENAI_API_KEY, etc.), LLM calls');
37
+ console.log(' go directly to the provider — they never touch our servers.');
38
+ console.log('');
39
+ console.log(' Run \x1b[1mskrypt security\x1b[0m for full details.');
40
+ console.log('');
41
+ markNoticeSeen('security-v1');
42
+ }
@@ -32,10 +32,6 @@ export interface ValidationResult {
32
32
  * Auto-fix a code example using LLM
33
33
  */
34
34
  export declare function autoFixExample(example: CodeExample, client: LLMClient, options?: AutoFixOptions): Promise<FixResult>;
35
- /**
36
- * Batch auto-fix multiple examples
37
- */
38
- export declare function autoFixBatch(examples: CodeExample[], client: LLMClient, options?: AutoFixOptions): Promise<Map<number, FixResult>>;
39
35
  /**
40
36
  * Create a TypeScript validator using tsc
41
37
  */
@@ -137,34 +137,20 @@ function extractCode(response, language) {
137
137
  const genericMatch = response.match(/```\w*\n([\s\S]*?)\n```/);
138
138
  if (genericMatch?.[1])
139
139
  return genericMatch[1].trim();
140
- // Try to find code without block
140
+ // Try to find code without block — only accept if it looks like actual code
141
141
  const lines = response.split('\n');
142
142
  const codeLines = lines.filter(line => {
143
143
  const trimmed = line.trim();
144
144
  return trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('//');
145
145
  });
146
- return codeLines.length > 0 ? codeLines.join('\n') : null;
147
- }
148
- /**
149
- * Batch auto-fix multiple examples
150
- */
151
- export async function autoFixBatch(examples, client, options = {}) {
152
- const results = new Map();
153
- for (let i = 0; i < examples.length; i++) {
154
- const example = examples[i];
155
- if (!example)
156
- continue;
157
- console.log(` [${i + 1}/${examples.length}] Fixing ${example.language} example...`);
158
- const result = await autoFixExample(example, client, options);
159
- results.set(i, result);
160
- if (result.success) {
161
- console.log(` ✓ Fixed in ${result.iterations} iteration(s)`);
162
- }
163
- else {
164
- console.log(` ✗ Could not fix after ${result.iterations} iteration(s)`);
165
- }
166
- }
167
- return results;
146
+ if (codeLines.length === 0)
147
+ return null;
148
+ const joined = codeLines.join('\n');
149
+ // Require at least some code-like tokens to avoid returning prose as code
150
+ const codeTokens = /[=(){}[\];:,]|def |class |function |const |let |var |import |from |return /;
151
+ if (!codeTokens.test(joined))
152
+ return null;
153
+ return joined;
168
154
  }
169
155
  /**
170
156
  * Create a TypeScript validator using tsc
@@ -212,7 +198,7 @@ export function createPythonValidator() {
212
198
  const { randomUUID } = await import('crypto');
213
199
  // Use crypto.randomUUID() to avoid timestamp collisions
214
200
  const tempFile = join(tmpdir(), `autofix_${randomUUID()}.py`);
215
- writeFileSync(tempFile, code);
201
+ writeFileSync(tempFile, code, { mode: 0o600 });
216
202
  try {
217
203
  // Use spawnSync with array args to avoid command injection
218
204
  const result = spawnSync('python3', ['-m', 'py_compile', tempFile], {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Playwright wrapper for screenshot capture.
3
+ *
4
+ * Dynamically imports Playwright (optional dependency).
5
+ * Only allows localhost URLs by default for security.
6
+ */
7
+ import type { ScreenshotDirective, ScreenshotResult, CaptureOptions } from './types.js';
8
+ /**
9
+ * Capture screenshots for a batch of directives.
10
+ */
11
+ export declare function captureScreenshots(directives: ScreenshotDirective[], options: CaptureOptions): Promise<ScreenshotResult[]>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Playwright wrapper for screenshot capture.
3
+ *
4
+ * Dynamically imports Playwright (optional dependency).
5
+ * Only allows localhost URLs by default for security.
6
+ */
7
+ import { screenshotFilename, darkVariantFilename } from './naming.js';
8
+ import { computeHash } from './diff.js';
9
+ const ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
10
+ /**
11
+ * Dynamically import Playwright. Exits with clear instructions if not installed.
12
+ */
13
+ async function ensurePlaywright() {
14
+ try {
15
+ // Dynamic import — playwright is optional
16
+ const mod = await Function('return import("playwright")')();
17
+ return mod;
18
+ }
19
+ catch {
20
+ throw new Error('Playwright is required for screenshot capture. Install it: npx playwright install chromium');
21
+ }
22
+ }
23
+ /**
24
+ * Validate that a URL is on an allowed host (localhost by default).
25
+ */
26
+ function validateUrl(url) {
27
+ try {
28
+ const parsed = new URL(url);
29
+ return { valid: ALLOWED_HOSTS.has(parsed.hostname), host: parsed.hostname };
30
+ }
31
+ catch {
32
+ return { valid: false, host: '' };
33
+ }
34
+ }
35
+ /**
36
+ * Capture screenshots for a batch of directives.
37
+ */
38
+ export async function captureScreenshots(directives, options) {
39
+ const pw = await ensurePlaywright();
40
+ const results = [];
41
+ const browser = await pw.chromium.launch({ headless: true });
42
+ try {
43
+ // Process with concurrency limit
44
+ const chunks = [];
45
+ for (let i = 0; i < directives.length; i += options.concurrency) {
46
+ chunks.push(directives.slice(i, i + options.concurrency));
47
+ }
48
+ for (const chunk of chunks) {
49
+ const chunkResults = await Promise.all(chunk.map(directive => captureOne(browser, directive, options, pw)));
50
+ results.push(...chunkResults);
51
+ }
52
+ }
53
+ finally {
54
+ await browser.close();
55
+ }
56
+ return results;
57
+ }
58
+ /**
59
+ * Capture a single screenshot.
60
+ */
61
+ async function captureOne(browser, directive, options, pw) {
62
+ const start = Date.now();
63
+ const filename = screenshotFilename(directive.url, directive.selector);
64
+ // Security check
65
+ const { valid, host } = validateUrl(directive.url);
66
+ if (!valid) {
67
+ return {
68
+ directive,
69
+ filename,
70
+ status: 'failed',
71
+ error: `URL host "${host}" not allowed. Only localhost, 127.0.0.1, and ::1 are permitted.`,
72
+ duration: Date.now() - start,
73
+ };
74
+ }
75
+ // Build context options
76
+ const contextOpts = {
77
+ viewport: options.viewport,
78
+ };
79
+ // Device emulation
80
+ if (options.device) {
81
+ const device = pw.devices[options.device];
82
+ if (device) {
83
+ contextOpts.viewport = device.viewport;
84
+ contextOpts.userAgent = device.userAgent;
85
+ contextOpts.deviceScaleFactor = device.deviceScaleFactor;
86
+ contextOpts.isMobile = device.isMobile;
87
+ contextOpts.hasTouch = device.hasTouch;
88
+ }
89
+ }
90
+ const context = await browser.newContext(contextOpts);
91
+ const page = await context.newPage();
92
+ try {
93
+ // Navigate
94
+ await page.goto(directive.url, {
95
+ waitUntil: 'networkidle',
96
+ timeout: options.timeout,
97
+ });
98
+ // Wait for settle time
99
+ await page.waitForTimeout(options.wait);
100
+ // Capture light mode screenshot
101
+ let buffer;
102
+ if (directive.selector) {
103
+ const locator = page.locator(directive.selector);
104
+ const visible = await locator.isVisible();
105
+ if (!visible) {
106
+ return {
107
+ directive,
108
+ filename,
109
+ status: 'failed',
110
+ error: `Selector "${directive.selector}" not found or not visible on ${directive.url}`,
111
+ duration: Date.now() - start,
112
+ };
113
+ }
114
+ buffer = await locator.screenshot({ type: 'png' });
115
+ }
116
+ else {
117
+ buffer = await page.screenshot({ fullPage: true, type: 'png' });
118
+ }
119
+ const hash = computeHash(buffer);
120
+ const result = {
121
+ directive,
122
+ filename,
123
+ status: 'captured',
124
+ hash,
125
+ duration: Date.now() - start,
126
+ };
127
+ // Dark mode capture
128
+ if (options.dark) {
129
+ try {
130
+ await page.emulateMedia({ colorScheme: 'dark' });
131
+ await page.evaluate(() => {
132
+ document.documentElement.classList.add('dark');
133
+ });
134
+ await page.waitForTimeout(500);
135
+ let darkBuffer;
136
+ if (directive.selector) {
137
+ darkBuffer = await page.locator(directive.selector).screenshot({ type: 'png' });
138
+ }
139
+ else {
140
+ darkBuffer = await page.screenshot({ fullPage: true, type: 'png' });
141
+ }
142
+ result.darkFilename = darkVariantFilename(filename);
143
+ result._buffers = {
144
+ light: buffer,
145
+ dark: darkBuffer,
146
+ };
147
+ }
148
+ catch {
149
+ // Dark mode capture failed — continue with light only
150
+ }
151
+ }
152
+ // Store light buffer for the orchestrator
153
+ if (!result._buffers) {
154
+ ;
155
+ result._buffer = buffer;
156
+ }
157
+ return result;
158
+ }
159
+ catch (err) {
160
+ const message = err instanceof Error ? err.message : String(err);
161
+ return {
162
+ directive,
163
+ filename,
164
+ status: 'failed',
165
+ error: message,
166
+ duration: Date.now() - start,
167
+ };
168
+ }
169
+ finally {
170
+ await page.close();
171
+ await context.close();
172
+ }
173
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Hash-based change detection for screenshots.
3
+ *
4
+ * Stores SHA-256 hashes in .skrypt/screenshot-hashes.json (project root).
5
+ * Used with --diff flag to skip re-capturing unchanged screenshots.
6
+ */
7
+ export type HashStore = Record<string, string>;
8
+ /**
9
+ * Compute SHA-256 hash of a buffer.
10
+ */
11
+ export declare function computeHash(buffer: Buffer): string;
12
+ /**
13
+ * Load the hash store from .skrypt/screenshot-hashes.json in the project directory.
14
+ */
15
+ export declare function loadHashStore(projectDir: string): HashStore;
16
+ /**
17
+ * Save the hash store to .skrypt/screenshot-hashes.json in the project directory.
18
+ */
19
+ export declare function saveHashStore(projectDir: string, store: HashStore): void;
20
+ /**
21
+ * Check if a screenshot has changed based on its hash.
22
+ */
23
+ export declare function hasChanged(filename: string, newHash: string, store: HashStore): boolean;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Hash-based change detection for screenshots.
3
+ *
4
+ * Stores SHA-256 hashes in .skrypt/screenshot-hashes.json (project root).
5
+ * Used with --diff flag to skip re-capturing unchanged screenshots.
6
+ */
7
+ import { createHash } from 'crypto';
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ const HASH_FILE = 'screenshot-hashes.json';
11
+ const STORE_DIR = '.skrypt';
12
+ /**
13
+ * Compute SHA-256 hash of a buffer.
14
+ */
15
+ export function computeHash(buffer) {
16
+ return createHash('sha256').update(buffer).digest('hex');
17
+ }
18
+ /**
19
+ * Load the hash store from .skrypt/screenshot-hashes.json in the project directory.
20
+ */
21
+ export function loadHashStore(projectDir) {
22
+ const storePath = join(projectDir, STORE_DIR, HASH_FILE);
23
+ if (!existsSync(storePath)) {
24
+ return {};
25
+ }
26
+ try {
27
+ const content = readFileSync(storePath, 'utf-8');
28
+ const parsed = JSON.parse(content);
29
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
30
+ return parsed;
31
+ }
32
+ return {};
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ }
38
+ /**
39
+ * Save the hash store to .skrypt/screenshot-hashes.json in the project directory.
40
+ */
41
+ export function saveHashStore(projectDir, store) {
42
+ const storeDir = join(projectDir, STORE_DIR);
43
+ mkdirSync(storeDir, { recursive: true });
44
+ const storePath = join(storeDir, HASH_FILE);
45
+ writeFileSync(storePath, JSON.stringify(store, null, 2), 'utf-8');
46
+ }
47
+ /**
48
+ * Check if a screenshot has changed based on its hash.
49
+ */
50
+ export function hasChanged(filename, newHash, store) {
51
+ return store[filename] !== newHash;
52
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Screenshot capture orchestrator.
3
+ *
4
+ * Coordinates MDX parsing, browser capture, diff detection, and file output.
5
+ */
6
+ import type { CaptureOptions, CaptureReport } from './types.js';
7
+ export type { CaptureOptions, CaptureReport } from './types.js';
8
+ /**
9
+ * Run the screenshot capture pipeline.
10
+ *
11
+ * 1. Find all MDX files in docsDir
12
+ * 2. Parse <Screenshot> directives from each
13
+ * 3. Validate base URL reachability
14
+ * 4. Capture screenshots via headless browser
15
+ * 5. Compare hashes for --diff mode
16
+ * 6. Write changed files to outputDir
17
+ * 7. Write manifest and hash store
18
+ */
19
+ export declare function runCapture(docsDir: string, options: CaptureOptions): Promise<CaptureReport>;
20
+ /**
21
+ * Print a capture report to stdout.
22
+ */
23
+ export declare function printCaptureReport(report: CaptureReport): void;