edsger 0.35.2 → 0.36.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.
@@ -0,0 +1,60 @@
1
+ export interface IAPLocalization {
2
+ name: string;
3
+ description?: string;
4
+ }
5
+ export interface IAPProduct {
6
+ id: string;
7
+ apple_id?: string;
8
+ product_id: string;
9
+ type: 'consumable' | 'non_consumable' | 'non_renewing_subscription' | 'auto_renewable_subscription';
10
+ family_shareable: boolean;
11
+ review_note?: string;
12
+ status: string;
13
+ subscription_group?: string;
14
+ subscription_group_apple_id?: string;
15
+ duration?: string;
16
+ base_territory: string;
17
+ base_price?: string;
18
+ localizations: Record<string, IAPLocalization>;
19
+ }
20
+ /**
21
+ * List all IAP products for a config
22
+ */
23
+ export declare function listIAPProducts(configId: string, verbose?: boolean): Promise<IAPProduct[]>;
24
+ /**
25
+ * Get a single IAP product
26
+ */
27
+ export declare function getIAPProduct(configId: string, iapId: string, verbose?: boolean): Promise<IAPProduct | null>;
28
+ /**
29
+ * Create a new IAP product (local draft)
30
+ */
31
+ export declare function createIAPProduct(params: {
32
+ config_id: string;
33
+ product_id: string;
34
+ type: IAPProduct['type'];
35
+ family_shareable?: boolean;
36
+ review_note?: string;
37
+ subscription_group?: string;
38
+ duration?: string;
39
+ base_territory?: string;
40
+ base_price?: string;
41
+ localizations?: Record<string, IAPLocalization>;
42
+ }, verbose?: boolean): Promise<IAPProduct | null>;
43
+ /**
44
+ * Update an existing IAP product (local)
45
+ */
46
+ export declare function updateIAPProduct(configId: string, iapId: string, updates: {
47
+ review_note?: string;
48
+ family_shareable?: boolean;
49
+ base_territory?: string;
50
+ base_price?: string;
51
+ localizations?: Record<string, IAPLocalization>;
52
+ }, verbose?: boolean): Promise<IAPProduct | null>;
53
+ /**
54
+ * Delete an IAP product (local)
55
+ */
56
+ export declare function deleteIAPProduct(configId: string, iapId: string, verbose?: boolean): Promise<boolean>;
57
+ /**
58
+ * Save the full IAP array (batch replacement)
59
+ */
60
+ export declare function saveIAPProducts(configId: string, iaps: IAPProduct[], verbose?: boolean): Promise<IAPProduct[]>;
@@ -0,0 +1,145 @@
1
+ import { logError, logInfo } from '../utils/logger.js';
2
+ import { callMcpEndpoint } from './mcp-client.js';
3
+ // ========== MCP CRUD (local DB) ==========
4
+ /**
5
+ * List all IAP products for a config
6
+ */
7
+ export async function listIAPProducts(configId, verbose) {
8
+ if (verbose) {
9
+ logInfo(`Fetching IAPs for config: ${configId}`);
10
+ }
11
+ try {
12
+ const result = (await callMcpEndpoint('app_store/iap/list', {
13
+ config_id: configId,
14
+ }));
15
+ const text = result.content?.[0]?.text || '[]';
16
+ try {
17
+ return JSON.parse(text);
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ catch (error) {
24
+ if (verbose) {
25
+ logError(`Failed to fetch IAPs: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ return [];
28
+ }
29
+ }
30
+ /**
31
+ * Get a single IAP product
32
+ */
33
+ export async function getIAPProduct(configId, iapId, verbose) {
34
+ try {
35
+ const result = (await callMcpEndpoint('app_store/iap/get', {
36
+ config_id: configId,
37
+ iap_id: iapId,
38
+ }));
39
+ const text = result.content?.[0]?.text || 'null';
40
+ try {
41
+ return JSON.parse(text);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ catch (error) {
48
+ if (verbose) {
49
+ logError(`Failed to fetch IAP: ${error instanceof Error ? error.message : String(error)}`);
50
+ }
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Create a new IAP product (local draft)
56
+ */
57
+ export async function createIAPProduct(params, verbose) {
58
+ if (verbose) {
59
+ logInfo(`Creating IAP: ${params.product_id} (${params.type})`);
60
+ }
61
+ try {
62
+ const result = (await callMcpEndpoint('app_store/iap/create', params));
63
+ const text = result.content?.[0]?.text || 'null';
64
+ try {
65
+ return JSON.parse(text);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ catch (error) {
72
+ logError(`Failed to create IAP: ${error instanceof Error ? error.message : String(error)}`);
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * Update an existing IAP product (local)
78
+ */
79
+ export async function updateIAPProduct(configId, iapId, updates, verbose) {
80
+ if (verbose) {
81
+ logInfo(`Updating IAP: ${iapId}`);
82
+ }
83
+ try {
84
+ const result = (await callMcpEndpoint('app_store/iap/update', {
85
+ config_id: configId,
86
+ iap_id: iapId,
87
+ ...updates,
88
+ }));
89
+ const text = result.content?.[0]?.text || 'null';
90
+ try {
91
+ return JSON.parse(text);
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ catch (error) {
98
+ logError(`Failed to update IAP: ${error instanceof Error ? error.message : String(error)}`);
99
+ return null;
100
+ }
101
+ }
102
+ /**
103
+ * Delete an IAP product (local)
104
+ */
105
+ export async function deleteIAPProduct(configId, iapId, verbose) {
106
+ if (verbose) {
107
+ logInfo(`Deleting IAP: ${iapId}`);
108
+ }
109
+ try {
110
+ await callMcpEndpoint('app_store/iap/delete', {
111
+ config_id: configId,
112
+ iap_id: iapId,
113
+ });
114
+ return true;
115
+ }
116
+ catch (error) {
117
+ logError(`Failed to delete IAP: ${error instanceof Error ? error.message : String(error)}`);
118
+ return false;
119
+ }
120
+ }
121
+ /**
122
+ * Save the full IAP array (batch replacement)
123
+ */
124
+ export async function saveIAPProducts(configId, iaps, verbose) {
125
+ if (verbose) {
126
+ logInfo(`Saving ${iaps.length} IAP(s) for config: ${configId}`);
127
+ }
128
+ try {
129
+ const result = (await callMcpEndpoint('app_store/iap/save', {
130
+ config_id: configId,
131
+ in_app_purchases: iaps,
132
+ }));
133
+ const text = result.content?.[0]?.text || '[]';
134
+ try {
135
+ return JSON.parse(text);
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ }
141
+ catch (error) {
142
+ logError(`Failed to save IAPs: ${error instanceof Error ? error.message : String(error)}`);
143
+ return [];
144
+ }
145
+ }
@@ -1,3 +1,10 @@
1
+ export interface BuildConfig {
2
+ project_path?: string;
3
+ scheme?: string;
4
+ configuration?: string;
5
+ team_id?: string;
6
+ export_method?: string;
7
+ }
1
8
  export interface AppStoreConfig {
2
9
  id: string;
3
10
  product_id: string;
@@ -6,6 +13,7 @@ export interface AppStoreConfig {
6
13
  app_identifier: string | null;
7
14
  listings: Record<string, AppStoreListing>;
8
15
  screenshots: Record<string, AppStoreScreenshot[]>;
16
+ build_config: BuildConfig;
9
17
  current_version: string | null;
10
18
  submission_status: string;
11
19
  submitted_at: string | null;
@@ -79,3 +87,7 @@ export declare function updateAppStoreStatus(configId: string, updates: {
79
87
  * Returns the locale string (e.g. "en-US") or null if unavailable.
80
88
  */
81
89
  export declare function getAppStorePrimaryLocale(productId: string, verbose?: boolean): Promise<string | null>;
90
+ /**
91
+ * Update build configuration for an app store config via MCP
92
+ */
93
+ export declare function updateBuildConfig(configId: string, buildConfig: BuildConfig, verbose?: boolean): Promise<AppStoreConfig | null>;
@@ -179,3 +179,28 @@ export async function getAppStorePrimaryLocale(productId, verbose) {
179
179
  return null;
180
180
  }
181
181
  }
182
+ /**
183
+ * Update build configuration for an app store config via MCP
184
+ */
185
+ export async function updateBuildConfig(configId, buildConfig, verbose) {
186
+ if (verbose) {
187
+ logInfo(`Saving build config for: ${configId}`);
188
+ }
189
+ try {
190
+ const result = (await callMcpEndpoint('app_store/configs/save_build_config', {
191
+ config_id: configId,
192
+ build_config: buildConfig,
193
+ }));
194
+ const text = result.content?.[0]?.text || 'null';
195
+ try {
196
+ return JSON.parse(text);
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ catch (error) {
203
+ logError(`Failed to save build config: ${error instanceof Error ? error.message : String(error)}`);
204
+ return null;
205
+ }
206
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Unit tests for the build command's pure/utility functions.
3
+ * Tests project discovery, scheme parsing, plist generation, and API key management.
4
+ */
5
+ export {};
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Unit tests for the build command's pure/utility functions.
3
+ * Tests project discovery, scheme parsing, plist generation, and API key management.
4
+ */
5
+ import assert from 'node:assert';
6
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { afterEach, beforeEach, describe, it } from 'node:test';
10
+ import { cleanupApiKeyFile, discoverSchemes, findXcodeProjects, generateExportOptionsPlist, writeApiKeyFile, } from '../index.js';
11
+ // ── Helpers ────────────────────────────────────────────────────────
12
+ function createTmpDir() {
13
+ const dir = join(tmpdir(), `edsger-build-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
+ mkdirSync(dir, { recursive: true });
15
+ return dir;
16
+ }
17
+ // ── findXcodeProjects ──────────────────────────────────────────────
18
+ describe('findXcodeProjects', () => {
19
+ let tmpDir;
20
+ beforeEach(() => {
21
+ tmpDir = createTmpDir();
22
+ });
23
+ afterEach(() => {
24
+ rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+ it('returns empty array for directory with no Xcode projects', () => {
27
+ mkdirSync(join(tmpDir, 'src'), { recursive: true });
28
+ writeFileSync(join(tmpDir, 'src', 'main.swift'), '');
29
+ const results = findXcodeProjects(tmpDir);
30
+ assert.deepStrictEqual(results, []);
31
+ });
32
+ it('finds a single .xcodeproj', () => {
33
+ mkdirSync(join(tmpDir, 'MyApp.xcodeproj'), { recursive: true });
34
+ const results = findXcodeProjects(tmpDir);
35
+ assert.strictEqual(results.length, 1);
36
+ assert.strictEqual(results[0].path, 'MyApp.xcodeproj');
37
+ assert.strictEqual(results[0].type, 'project');
38
+ });
39
+ it('finds a single .xcworkspace', () => {
40
+ mkdirSync(join(tmpDir, 'MyApp.xcworkspace'), { recursive: true });
41
+ const results = findXcodeProjects(tmpDir);
42
+ assert.strictEqual(results.length, 1);
43
+ assert.strictEqual(results[0].path, 'MyApp.xcworkspace');
44
+ assert.strictEqual(results[0].type, 'workspace');
45
+ });
46
+ it('sorts workspaces before projects', () => {
47
+ mkdirSync(join(tmpDir, 'MyApp.xcodeproj'), { recursive: true });
48
+ mkdirSync(join(tmpDir, 'MyApp.xcworkspace'), { recursive: true });
49
+ const results = findXcodeProjects(tmpDir);
50
+ assert.strictEqual(results.length, 2);
51
+ assert.strictEqual(results[0].type, 'workspace');
52
+ assert.strictEqual(results[1].type, 'project');
53
+ });
54
+ it('finds projects in subdirectories', () => {
55
+ mkdirSync(join(tmpDir, 'ios', 'MyApp.xcworkspace'), { recursive: true });
56
+ const results = findXcodeProjects(tmpDir);
57
+ assert.strictEqual(results.length, 1);
58
+ assert.strictEqual(results[0].path, join('ios', 'MyApp.xcworkspace'));
59
+ });
60
+ it('excludes Pods directory', () => {
61
+ mkdirSync(join(tmpDir, 'Pods', 'SomePod.xcodeproj'), { recursive: true });
62
+ mkdirSync(join(tmpDir, 'MyApp.xcodeproj'), { recursive: true });
63
+ const results = findXcodeProjects(tmpDir);
64
+ assert.strictEqual(results.length, 1);
65
+ assert.strictEqual(results[0].path, 'MyApp.xcodeproj');
66
+ });
67
+ it('excludes node_modules directory', () => {
68
+ mkdirSync(join(tmpDir, 'node_modules', 'react-native', 'React.xcodeproj'), {
69
+ recursive: true,
70
+ });
71
+ mkdirSync(join(tmpDir, 'ios', 'MyApp.xcodeproj'), { recursive: true });
72
+ const results = findXcodeProjects(tmpDir);
73
+ assert.strictEqual(results.length, 1);
74
+ assert.strictEqual(results[0].path, join('ios', 'MyApp.xcodeproj'));
75
+ });
76
+ it('excludes DerivedData directory', () => {
77
+ mkdirSync(join(tmpDir, 'DerivedData', 'Build.xcodeproj'), {
78
+ recursive: true,
79
+ });
80
+ const results = findXcodeProjects(tmpDir);
81
+ assert.strictEqual(results.length, 0);
82
+ });
83
+ it('excludes .build directory', () => {
84
+ mkdirSync(join(tmpDir, '.build', 'Something.xcodeproj'), {
85
+ recursive: true,
86
+ });
87
+ const results = findXcodeProjects(tmpDir);
88
+ assert.strictEqual(results.length, 0);
89
+ });
90
+ it('sorts shallower paths first among same type', () => {
91
+ mkdirSync(join(tmpDir, 'deep', 'nested', 'A.xcodeproj'), {
92
+ recursive: true,
93
+ });
94
+ mkdirSync(join(tmpDir, 'B.xcodeproj'), { recursive: true });
95
+ const results = findXcodeProjects(tmpDir);
96
+ assert.strictEqual(results[0].path, 'B.xcodeproj');
97
+ });
98
+ it('does not recurse deeper than 4 levels', () => {
99
+ mkdirSync(join(tmpDir, 'a', 'b', 'c', 'd', 'e', 'Deep.xcodeproj'), {
100
+ recursive: true,
101
+ });
102
+ const results = findXcodeProjects(tmpDir);
103
+ assert.strictEqual(results.length, 0);
104
+ });
105
+ it('finds projects at exactly depth 4', () => {
106
+ mkdirSync(join(tmpDir, 'a', 'b', 'c', 'd', 'Ok.xcodeproj'), {
107
+ recursive: true,
108
+ });
109
+ const results = findXcodeProjects(tmpDir);
110
+ assert.strictEqual(results.length, 1);
111
+ });
112
+ });
113
+ // ── generateExportOptionsPlist ─────────────────────────────────────
114
+ describe('generateExportOptionsPlist', () => {
115
+ it('generates valid plist with app-store method', () => {
116
+ const plist = generateExportOptionsPlist('TEAM123', 'app-store');
117
+ assert.ok(plist.includes('<?xml version="1.0"'));
118
+ assert.ok(plist.includes('<string>app-store</string>'));
119
+ assert.ok(plist.includes('<string>TEAM123</string>'));
120
+ assert.ok(plist.includes('<key>uploadSymbols</key>'));
121
+ assert.ok(plist.includes('<true/>'));
122
+ assert.ok(plist.includes('<string>automatic</string>'));
123
+ });
124
+ it('generates plist with ad-hoc method', () => {
125
+ const plist = generateExportOptionsPlist('ABCD', 'ad-hoc');
126
+ assert.ok(plist.includes('<string>ad-hoc</string>'));
127
+ assert.ok(plist.includes('<string>ABCD</string>'));
128
+ });
129
+ it('generates plist with development method', () => {
130
+ const plist = generateExportOptionsPlist('XYZ', 'development');
131
+ assert.ok(plist.includes('<string>development</string>'));
132
+ });
133
+ it('includes all required keys', () => {
134
+ const plist = generateExportOptionsPlist('T', 'app-store');
135
+ assert.ok(plist.includes('<key>method</key>'));
136
+ assert.ok(plist.includes('<key>teamID</key>'));
137
+ assert.ok(plist.includes('<key>uploadSymbols</key>'));
138
+ assert.ok(plist.includes('<key>signingStyle</key>'));
139
+ });
140
+ it('escapes XML special characters in teamId', () => {
141
+ const plist = generateExportOptionsPlist('</string><key>evil</key><string>', 'app-store');
142
+ assert.ok(!plist.includes('<key>evil</key>'));
143
+ assert.ok(plist.includes('&lt;/string&gt;&lt;key&gt;evil&lt;/key&gt;&lt;string&gt;'));
144
+ });
145
+ it('escapes XML special characters in exportMethod', () => {
146
+ const plist = generateExportOptionsPlist('TEAM', 'a&b<c>');
147
+ assert.ok(plist.includes('a&amp;b&lt;c&gt;'));
148
+ });
149
+ });
150
+ // ── API key file management ────────────────────────────────────────
151
+ describe('writeApiKeyFile', () => {
152
+ const testKeyId = `test-key-${Date.now()}`;
153
+ const testPrivateKey = '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
154
+ afterEach(() => {
155
+ cleanupApiKeyFile(testKeyId);
156
+ });
157
+ it('writes key file with correct content', () => {
158
+ const keyPath = writeApiKeyFile(testKeyId, testPrivateKey);
159
+ assert.ok(existsSync(keyPath));
160
+ assert.strictEqual(readFileSync(keyPath, 'utf-8'), testPrivateKey);
161
+ });
162
+ it('writes key file to ~/.edsger/keys/ directory', () => {
163
+ const keyPath = writeApiKeyFile(testKeyId, testPrivateKey);
164
+ assert.ok(keyPath.includes('.edsger'));
165
+ assert.ok(keyPath.includes('keys'));
166
+ assert.ok(keyPath.endsWith(`.p8`));
167
+ });
168
+ it('names file as AuthKey_<keyId>.p8', () => {
169
+ const keyPath = writeApiKeyFile(testKeyId, testPrivateKey);
170
+ assert.ok(keyPath.endsWith(`AuthKey_${testKeyId}.p8`));
171
+ });
172
+ it('rejects keyId with path traversal characters', () => {
173
+ assert.throws(() => writeApiKeyFile('../../etc/evil', 'key'), (err) => err.message.includes('Invalid API key ID'));
174
+ });
175
+ it('rejects keyId with slashes', () => {
176
+ assert.throws(() => writeApiKeyFile('key/id', 'key'), (err) => err.message.includes('Invalid API key ID'));
177
+ });
178
+ });
179
+ describe('cleanupApiKeyFile', () => {
180
+ it('removes existing key file', () => {
181
+ const testKeyId = `cleanup-test-${Date.now()}`;
182
+ const keyPath = writeApiKeyFile(testKeyId, 'test');
183
+ assert.ok(existsSync(keyPath));
184
+ cleanupApiKeyFile(testKeyId);
185
+ assert.ok(!existsSync(keyPath));
186
+ });
187
+ it('does not throw for non-existent key', () => {
188
+ assert.doesNotThrow(() => {
189
+ cleanupApiKeyFile('non-existent-key-id');
190
+ });
191
+ });
192
+ });
193
+ // ── discoverSchemes ────────────────────────────────────────────────
194
+ // Note: These tests only run meaningfully on macOS with Xcode installed.
195
+ // They verify the parsing logic by checking that the function doesn't crash
196
+ // when given a non-existent project (returns empty array).
197
+ describe('discoverSchemes', () => {
198
+ it('returns empty array for non-existent project', () => {
199
+ const schemes = discoverSchemes('/tmp', 'nonexistent.xcodeproj', 'project');
200
+ assert.deepStrictEqual(schemes, []);
201
+ });
202
+ it('returns empty array for non-existent workspace', () => {
203
+ const schemes = discoverSchemes('/tmp', 'nonexistent.xcworkspace', 'workspace');
204
+ assert.deepStrictEqual(schemes, []);
205
+ });
206
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Integration tests for the runBuild orchestration function.
3
+ * Uses dependency injection (BuildDeps) to mock external calls
4
+ * while testing the full decision-making pipeline.
5
+ */
6
+ export {};