instant-cli 1.0.10 → 1.0.11
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/.turbo/turbo-build.log +1 -1
- package/__tests__/authClientAddApple.test.ts +374 -0
- package/__tests__/authClientAddGithub.test.ts +3 -0
- package/__tests__/authClientAddGoogle.test.ts +3 -0
- package/__tests__/e2e/cli.e2e.test.ts +1 -1
- package/dist/commands/auth/client/add.d.ts +2 -1
- package/dist/commands/auth/client/add.d.ts.map +1 -1
- package/dist/commands/auth/client/add.js +185 -7
- package/dist/commands/auth/client/add.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/auth/client/add.ts +234 -5
- package/src/index.ts +7 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { test, expect, describe, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Effect, Layer, Logger } from 'effect';
|
|
3
|
+
import { FileSystem } from '@effect/platform';
|
|
4
|
+
import { SystemError } from '@effect/platform/Error';
|
|
5
|
+
import { GlobalOpts } from '../src/context/globalOpts.ts';
|
|
6
|
+
import { CurrentApp } from '../src/context/currentApp.ts';
|
|
7
|
+
import { InstantHttpAuthed } from '../src/lib/http.ts';
|
|
8
|
+
|
|
9
|
+
// -- mocks --
|
|
10
|
+
|
|
11
|
+
// Prevent src/index.ts side-effect (program.parse) from running.
|
|
12
|
+
// add.ts has `import type` from index.ts, but vitest still evaluates it.
|
|
13
|
+
vi.mock('../src/index.ts', () => ({}));
|
|
14
|
+
|
|
15
|
+
let prompts: any[] = [];
|
|
16
|
+
let mockPromptReturn: any = '';
|
|
17
|
+
|
|
18
|
+
vi.mock('../src/ui/lib.ts', async (importOriginal) => {
|
|
19
|
+
const orig: any = await importOriginal();
|
|
20
|
+
return {
|
|
21
|
+
...orig,
|
|
22
|
+
renderUnwrap: (prompt: any) => {
|
|
23
|
+
prompts.push(prompt);
|
|
24
|
+
return Promise.resolve(mockPromptReturn);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let addedClients: any[] = [];
|
|
30
|
+
|
|
31
|
+
vi.mock('../src/lib/oauth.ts', () => ({
|
|
32
|
+
getAppsAuth: () =>
|
|
33
|
+
Effect.succeed({
|
|
34
|
+
oauth_service_providers: [{ id: 'prov-1', provider_name: 'apple' }],
|
|
35
|
+
oauth_clients: [],
|
|
36
|
+
}),
|
|
37
|
+
addOAuthProvider: () =>
|
|
38
|
+
Effect.succeed({
|
|
39
|
+
provider: { id: 'prov-1', provider_name: 'apple' },
|
|
40
|
+
}),
|
|
41
|
+
addOAuthClient: (params: any) => {
|
|
42
|
+
addedClients.push(params);
|
|
43
|
+
return Effect.succeed({
|
|
44
|
+
client: {
|
|
45
|
+
id: 'client-1',
|
|
46
|
+
client_name: params.clientName,
|
|
47
|
+
client_id: params.clientId,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
// Lazy import so mocks are in place
|
|
54
|
+
const { authClientAddCmd } = await import('../src/commands/auth/client/add.ts');
|
|
55
|
+
|
|
56
|
+
// -- helpers --
|
|
57
|
+
|
|
58
|
+
let logs: string[] = [];
|
|
59
|
+
|
|
60
|
+
// In-memory filesystem for the private-key file. Keys are paths, values are
|
|
61
|
+
// file contents. Requested paths that aren't present throw SystemError, which
|
|
62
|
+
// the Apple handler maps to BadArgsError.
|
|
63
|
+
let mockFiles: Record<string, string> = {};
|
|
64
|
+
|
|
65
|
+
const MockFileSystemLayer = FileSystem.layerNoop({
|
|
66
|
+
readFileString: (path: string) =>
|
|
67
|
+
path in mockFiles
|
|
68
|
+
? Effect.succeed(mockFiles[path])
|
|
69
|
+
: Effect.fail(
|
|
70
|
+
new SystemError({
|
|
71
|
+
reason: 'NotFound',
|
|
72
|
+
module: 'FileSystem',
|
|
73
|
+
method: 'readFileString',
|
|
74
|
+
pathOrDescriptor: path,
|
|
75
|
+
description: `no such file or directory, open '${path}'`,
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const run = (flags: Map<string, string>, { yes }: { yes: boolean }) =>
|
|
81
|
+
Effect.runPromise(
|
|
82
|
+
authClientAddCmd(Object.fromEntries(flags) as any).pipe(
|
|
83
|
+
Effect.provide(
|
|
84
|
+
Layer.mergeAll(
|
|
85
|
+
Layer.succeed(GlobalOpts, { yes }),
|
|
86
|
+
Layer.succeed(CurrentApp, { appId: 'test-app', source: 'env' }),
|
|
87
|
+
Layer.succeed(InstantHttpAuthed, {} as any),
|
|
88
|
+
// Mocked FileSystem so readPrivateKeyFile reads from `mockFiles`.
|
|
89
|
+
MockFileSystemLayer,
|
|
90
|
+
Logger.replace(
|
|
91
|
+
Logger.defaultLogger,
|
|
92
|
+
Logger.make(({ message }) => {
|
|
93
|
+
logs.push(String(message));
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const without = (flags: Map<string, string>, key: string) => {
|
|
102
|
+
const copy = new Map(flags);
|
|
103
|
+
copy.delete(key);
|
|
104
|
+
return copy;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const withEntry = (flags: Map<string, string>, key: string, value: string) =>
|
|
108
|
+
new Map([...flags, [key, value]]);
|
|
109
|
+
|
|
110
|
+
const PEM_CONTENTS =
|
|
111
|
+
'-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n';
|
|
112
|
+
const PEM_PATH = '/tmp/AuthKey_ABC123.p8';
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
prompts = [];
|
|
116
|
+
addedClients = [];
|
|
117
|
+
logs = [];
|
|
118
|
+
mockPromptReturn = '';
|
|
119
|
+
mockFiles = { [PEM_PATH]: PEM_CONTENTS };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// -- flag sets --
|
|
123
|
+
|
|
124
|
+
const nativeFlags = new Map([
|
|
125
|
+
['type', 'apple'],
|
|
126
|
+
['name', 'apple-native'],
|
|
127
|
+
['services-id', 'com.example.app'],
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const webFlags = new Map([
|
|
131
|
+
['type', 'apple'],
|
|
132
|
+
['name', 'apple-web'],
|
|
133
|
+
['services-id', 'com.example.web'],
|
|
134
|
+
['team-id', 'ABCD1234'],
|
|
135
|
+
['key-id', 'XYZ789'],
|
|
136
|
+
['private-key-file', PEM_PATH],
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
// -- native-only (no web flow): build-up with --yes --
|
|
140
|
+
|
|
141
|
+
describe('native: --yes errors on each missing required flag', () => {
|
|
142
|
+
test('missing --type', async () => {
|
|
143
|
+
await run(without(nativeFlags, 'type'), { yes: true });
|
|
144
|
+
expect(logs.join('\n')).toContain('Missing required value for --type');
|
|
145
|
+
expect(addedClients).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('missing --name', async () => {
|
|
149
|
+
await run(without(nativeFlags, 'name'), { yes: true });
|
|
150
|
+
expect(logs.join('\n')).toContain('Missing required value for --name');
|
|
151
|
+
expect(addedClients).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('missing --services-id', async () => {
|
|
155
|
+
await run(without(nativeFlags, 'services-id'), { yes: true });
|
|
156
|
+
expect(logs.join('\n')).toContain(
|
|
157
|
+
'Missing required value for --services-id',
|
|
158
|
+
);
|
|
159
|
+
expect(addedClients).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// -- native-only: interactive prompts --
|
|
164
|
+
|
|
165
|
+
describe('native: interactive prompts for each missing flag', () => {
|
|
166
|
+
test('missing --type → prompts type selector', async () => {
|
|
167
|
+
mockPromptReturn = 'apple';
|
|
168
|
+
await run(without(nativeFlags, 'type'), { yes: false });
|
|
169
|
+
expect((prompts[0] as any).params.promptText).toBe('Select a client type:');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('missing --name → prompts for name', async () => {
|
|
173
|
+
mockPromptReturn = 'apple-native';
|
|
174
|
+
await run(without(nativeFlags, 'name'), { yes: false });
|
|
175
|
+
expect((prompts[0] as any).props.prompt).toBe('Client Name:');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('missing --services-id → prompts for Services ID', async () => {
|
|
179
|
+
mockPromptReturn = 'com.example.app';
|
|
180
|
+
await run(without(nativeFlags, 'services-id'), { yes: false });
|
|
181
|
+
expect((prompts[0] as any).props.prompt).toContain('Services ID');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('no web-flow flags → prompts to configure web flow', async () => {
|
|
185
|
+
// mockPromptReturn = '' → confirmation treats as false (defaultValue)
|
|
186
|
+
await run(nativeFlags, { yes: false });
|
|
187
|
+
const confirm = prompts.find((p) =>
|
|
188
|
+
String(p?.props?.promptText ?? '').includes(
|
|
189
|
+
'Configure web redirect flow?',
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
expect(confirm).toBeDefined();
|
|
193
|
+
// With default = false, no additional web-flow prompts should appear
|
|
194
|
+
expect(addedClients).toHaveLength(1);
|
|
195
|
+
expect(addedClients[0].clientSecret).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('configure-web confirm → does not prompt with --yes', async () => {
|
|
199
|
+
await run(nativeFlags, { yes: true });
|
|
200
|
+
const confirm = prompts.find((p) =>
|
|
201
|
+
String(p?.props?.promptText ?? '').includes(
|
|
202
|
+
'Configure web redirect flow?',
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
expect(confirm).toBeUndefined();
|
|
206
|
+
expect(addedClients).toHaveLength(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// -- native-only: success --
|
|
211
|
+
|
|
212
|
+
describe('native: success', () => {
|
|
213
|
+
test('all required flags → creates client without secret or redirect', async () => {
|
|
214
|
+
await run(nativeFlags, { yes: true });
|
|
215
|
+
expect(addedClients).toHaveLength(1);
|
|
216
|
+
expect(addedClients[0]).toMatchObject({
|
|
217
|
+
clientName: 'apple-native',
|
|
218
|
+
clientId: 'com.example.app',
|
|
219
|
+
});
|
|
220
|
+
const output = logs.join('\n');
|
|
221
|
+
expect(output).toContain('Apple OAuth client created: apple-native');
|
|
222
|
+
expect(output).toContain('ID: client-1');
|
|
223
|
+
expect(output).toContain('Services ID: com.example.app');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// -- web flow: build-up with --yes --
|
|
228
|
+
|
|
229
|
+
describe('web: --yes errors on each missing required flag', () => {
|
|
230
|
+
test('missing --team-id', async () => {
|
|
231
|
+
await run(without(webFlags, 'team-id'), { yes: true });
|
|
232
|
+
expect(logs.join('\n')).toContain('Missing required value for --team-id');
|
|
233
|
+
expect(addedClients).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('missing --key-id', async () => {
|
|
237
|
+
await run(without(webFlags, 'key-id'), { yes: true });
|
|
238
|
+
expect(logs.join('\n')).toContain('Missing required value for --key-id');
|
|
239
|
+
expect(addedClients).toHaveLength(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('missing --private-key-file', async () => {
|
|
243
|
+
await run(without(webFlags, 'private-key-file'), { yes: true });
|
|
244
|
+
expect(logs.join('\n')).toContain(
|
|
245
|
+
'Missing required value for --private-key-file',
|
|
246
|
+
);
|
|
247
|
+
expect(addedClients).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// -- web: interactive prompts --
|
|
252
|
+
|
|
253
|
+
describe('web: interactive prompts for each missing flag', () => {
|
|
254
|
+
test('missing --team-id → prompts for Team ID', async () => {
|
|
255
|
+
mockPromptReturn = 'ABCD1234';
|
|
256
|
+
await run(without(webFlags, 'team-id'), { yes: false });
|
|
257
|
+
const p = prompts.find((p: any) =>
|
|
258
|
+
String(p?.props?.prompt ?? '').includes('Team ID'),
|
|
259
|
+
);
|
|
260
|
+
expect(p).toBeDefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('missing --key-id → prompts for Key ID', async () => {
|
|
264
|
+
mockPromptReturn = 'XYZ789';
|
|
265
|
+
await run(without(webFlags, 'key-id'), { yes: false });
|
|
266
|
+
const p = prompts.find((p: any) =>
|
|
267
|
+
String(p?.props?.prompt ?? '').includes('Key ID'),
|
|
268
|
+
);
|
|
269
|
+
expect(p).toBeDefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('missing --private-key-file → prompts for path', async () => {
|
|
273
|
+
mockPromptReturn = PEM_PATH;
|
|
274
|
+
await run(without(webFlags, 'private-key-file'), { yes: false });
|
|
275
|
+
const p = prompts.find((p: any) =>
|
|
276
|
+
String(p?.props?.prompt ?? '').includes('Path to .p8 private key file'),
|
|
277
|
+
);
|
|
278
|
+
expect(p).toBeDefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('custom-redirect-uri → prompts when omitted', async () => {
|
|
282
|
+
mockPromptReturn = '';
|
|
283
|
+
await run(webFlags, { yes: false });
|
|
284
|
+
const p = prompts.find(
|
|
285
|
+
(p: any) =>
|
|
286
|
+
p?.props?.placeholder === 'https://yoursite.com/oauth/callback',
|
|
287
|
+
);
|
|
288
|
+
expect(p).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('custom-redirect-uri → skipped with --yes', async () => {
|
|
292
|
+
await run(webFlags, { yes: true });
|
|
293
|
+
expect(prompts).toHaveLength(0);
|
|
294
|
+
expect(addedClients).toHaveLength(1);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// -- web: success --
|
|
299
|
+
|
|
300
|
+
describe('web: success', () => {
|
|
301
|
+
test('all required flags → creates client and prints redirect URL', async () => {
|
|
302
|
+
await run(webFlags, { yes: true });
|
|
303
|
+
expect(addedClients).toHaveLength(1);
|
|
304
|
+
expect(addedClients[0]).toMatchObject({
|
|
305
|
+
clientName: 'apple-web',
|
|
306
|
+
clientId: 'com.example.web',
|
|
307
|
+
clientSecret: PEM_CONTENTS.trim(),
|
|
308
|
+
redirectTo: 'https://api.instantdb.com/runtime/oauth/callback',
|
|
309
|
+
meta: { teamId: 'ABCD1234', keyId: 'XYZ789' },
|
|
310
|
+
});
|
|
311
|
+
const output = logs.join('\n');
|
|
312
|
+
expect(output).toContain('Apple OAuth client created: apple-web');
|
|
313
|
+
expect(output).toContain('Services ID: com.example.web');
|
|
314
|
+
expect(output).toContain('Team ID: ABCD1234');
|
|
315
|
+
expect(output).toContain('Key ID: XYZ789');
|
|
316
|
+
expect(output).toContain(
|
|
317
|
+
'Add this return URL under your Services ID on developer.apple.com:',
|
|
318
|
+
);
|
|
319
|
+
expect(output).toContain(
|
|
320
|
+
'https://api.instantdb.com/runtime/oauth/callback',
|
|
321
|
+
);
|
|
322
|
+
expect(output).not.toContain('Native-only flow configured.');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('with custom-redirect-uri → uses it and prints forwarding instructions', async () => {
|
|
326
|
+
await run(
|
|
327
|
+
withEntry(webFlags, 'custom-redirect-uri', 'https://myapp.com/cb'),
|
|
328
|
+
{ yes: true },
|
|
329
|
+
);
|
|
330
|
+
expect(addedClients[0].redirectTo).toBe('https://myapp.com/cb');
|
|
331
|
+
const output = logs.join('\n');
|
|
332
|
+
expect(output).toContain('https://myapp.com/cb');
|
|
333
|
+
expect(output).toContain(
|
|
334
|
+
'https://api.instantdb.com/runtime/oauth/callback with all query parameters',
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// -- private key file: error paths --
|
|
340
|
+
|
|
341
|
+
describe('private key file errors', () => {
|
|
342
|
+
test('file does not exist → BadArgsError', async () => {
|
|
343
|
+
mockFiles = {}; // remove the PEM
|
|
344
|
+
await run(webFlags, { yes: true });
|
|
345
|
+
expect(logs.join('\n')).toContain(
|
|
346
|
+
`Could not read private key file at ${PEM_PATH}`,
|
|
347
|
+
);
|
|
348
|
+
expect(addedClients).toHaveLength(0);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('file is empty → BadArgsError', async () => {
|
|
352
|
+
mockFiles = { [PEM_PATH]: ' \n ' };
|
|
353
|
+
await run(webFlags, { yes: true });
|
|
354
|
+
expect(logs.join('\n')).toContain(
|
|
355
|
+
`Private key file at ${PEM_PATH} is empty.`,
|
|
356
|
+
);
|
|
357
|
+
expect(addedClients).toHaveLength(0);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// -- native-only: stray web-only flag rejection --
|
|
362
|
+
|
|
363
|
+
describe('native: web-only flags rejected when web flow not configured', () => {
|
|
364
|
+
test('--custom-redirect-uri without web flow → error', async () => {
|
|
365
|
+
await run(
|
|
366
|
+
withEntry(nativeFlags, 'custom-redirect-uri', 'https://example.com'),
|
|
367
|
+
{ yes: true },
|
|
368
|
+
);
|
|
369
|
+
expect(logs.join('\n')).toContain(
|
|
370
|
+
'--custom-redirect-uri requires configuring the web redirect flow',
|
|
371
|
+
);
|
|
372
|
+
expect(addedClients).toHaveLength(0);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test, expect, describe, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { Effect, Layer, Logger } from 'effect';
|
|
3
|
+
import { NodeContext } from '@effect/platform-node';
|
|
3
4
|
import { GlobalOpts } from '../src/context/globalOpts.ts';
|
|
4
5
|
import { CurrentApp } from '../src/context/currentApp.ts';
|
|
5
6
|
import { InstantHttpAuthed } from '../src/lib/http.ts';
|
|
@@ -63,6 +64,8 @@ const run = (flags: Map<string, string>, { yes }: { yes: boolean }) =>
|
|
|
63
64
|
Layer.succeed(GlobalOpts, { yes }),
|
|
64
65
|
Layer.succeed(CurrentApp, { appId: 'test-app', source: 'env' }),
|
|
65
66
|
Layer.succeed(InstantHttpAuthed, {} as any),
|
|
67
|
+
// Provides FileSystem (required by the Apple handler).
|
|
68
|
+
NodeContext.layer,
|
|
66
69
|
Logger.replace(
|
|
67
70
|
Logger.defaultLogger,
|
|
68
71
|
Logger.make(({ message }) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test, expect, describe, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { Effect, Layer, Logger } from 'effect';
|
|
3
|
+
import { NodeContext } from '@effect/platform-node';
|
|
3
4
|
import { GlobalOpts } from '../src/context/globalOpts.ts';
|
|
4
5
|
import { CurrentApp } from '../src/context/currentApp.ts';
|
|
5
6
|
import { InstantHttpAuthed } from '../src/lib/http.ts';
|
|
@@ -63,6 +64,8 @@ const run = (flags: Map<string, string>, { yes }: { yes: boolean }) =>
|
|
|
63
64
|
Layer.succeed(GlobalOpts, { yes }),
|
|
64
65
|
Layer.succeed(CurrentApp, { appId: 'test-app', source: 'env' }),
|
|
65
66
|
Layer.succeed(InstantHttpAuthed, {} as any),
|
|
67
|
+
// Provides FileSystem (required by the Apple handler).
|
|
68
|
+
NodeContext.layer,
|
|
66
69
|
Logger.replace(
|
|
67
70
|
Logger.defaultLogger,
|
|
68
71
|
Logger.make(({ message }) => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Effect } from 'effect';
|
|
2
|
+
import { FileSystem } from '@effect/platform';
|
|
2
3
|
import { GlobalOpts } from '../../../context/globalOpts.ts';
|
|
3
4
|
export declare const authClientAddCmd: (opts: {
|
|
4
5
|
type?: string | undefined;
|
|
5
6
|
name?: string | undefined;
|
|
6
7
|
app?: string | undefined;
|
|
7
|
-
} & Record<string, unknown>) => Effect.Effect<void | undefined, import("../../../lib/ui.ts").UIError | import("../../../lib/http.ts").InstantHttpError | import("effect/Cause").TimeoutException | import("@effect/platform/HttpClientError").RequestError | import("effect/ParseResult").ParseError | import("@effect/platform/HttpClientError").ResponseError, GlobalOpts | import("../../../lib/http.ts").InstantHttpAuthed | import("../../../context/currentApp.ts").CurrentApp>;
|
|
8
|
+
} & Record<string, unknown>) => Effect.Effect<void | undefined, import("../../../lib/ui.ts").UIError | import("../../../lib/http.ts").InstantHttpError | import("effect/Cause").TimeoutException | import("@effect/platform/HttpClientError").RequestError | import("effect/ParseResult").ParseError | import("@effect/platform/HttpClientError").ResponseError, GlobalOpts | FileSystem.FileSystem | import("../../../lib/http.ts").InstantHttpAuthed | import("../../../context/currentApp.ts").CurrentApp>;
|
|
8
9
|
//# sourceMappingURL=add.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../../../src/commands/auth/client/add.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAyB,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../../../src/commands/auth/client/add.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAyB,MAAM,QAAQ,CAAC;AACvD,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAG9C,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AA0lB5D,eAAO,MAAM,gBAAgB;;;;6eAyD5B,CAAC"}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Effect, Match, Option, Schema } from 'effect';
|
|
2
|
+
import { FileSystem } from '@effect/platform';
|
|
2
3
|
import { BadArgsError } from "../../../errors.js";
|
|
3
4
|
import { GlobalOpts } from "../../../context/globalOpts.js";
|
|
4
|
-
import { optOrPrompt, runUIEffect, stripFirstBlankLine, validateRequired, } from "../../../lib/ui.js";
|
|
5
|
+
import { optOrPrompt, optOrPromptBoolean, runUIEffect, stripFirstBlankLine, validateRequired, } from "../../../lib/ui.js";
|
|
5
6
|
import { addOAuthClient, addOAuthProvider, getAppsAuth, } from "../../../lib/oauth.js";
|
|
6
|
-
import { GOOGLE_AUTHORIZATION_ENDPOINT, GOOGLE_DEFAULT_CALLBACK_URL, GOOGLE_DISCOVERY_ENDPOINT, GOOGLE_TOKEN_ENDPOINT, } from '@instantdb/platform';
|
|
7
|
+
import { GOOGLE_AUTHORIZATION_ENDPOINT, GOOGLE_DEFAULT_CALLBACK_URL, GOOGLE_DISCOVERY_ENDPOINT, GOOGLE_TOKEN_ENDPOINT, APPLE_AUTHORIZATION_ENDPOINT, APPLE_DEFAULT_CALLBACK_URL, APPLE_DISCOVERY_ENDPOINT, APPLE_TOKEN_ENDPOINT, } from '@instantdb/platform';
|
|
7
8
|
import { UI } from "../../../ui/index.js";
|
|
8
9
|
import chalk from 'chalk';
|
|
9
10
|
import boxen from 'boxen';
|
|
10
|
-
const ClientTypeSchema = Schema.Literal('google', 'github');
|
|
11
|
+
const ClientTypeSchema = Schema.Literal('google', 'github', 'apple');
|
|
11
12
|
const GoogleAppTypeSchema = Schema.Literal('web', 'ios', 'android', 'button-for-web');
|
|
12
13
|
const selectGoogleAppType = (value) => Effect.gen(function* () {
|
|
13
14
|
const { yes } = yield* GlobalOpts;
|
|
@@ -262,6 +263,184 @@ ${chalk.dim('Your URI must forward to https://api.instantdb.com/runtime/oauth/ca
|
|
|
262
263
|
...redirectMessages,
|
|
263
264
|
].join('\n'), { dimBorder: true, padding: { right: 1, left: 1 } }));
|
|
264
265
|
});
|
|
266
|
+
const readPrivateKeyFile = Effect.fn('readPrivateKeyFile')(function* (path) {
|
|
267
|
+
const fs = yield* FileSystem.FileSystem;
|
|
268
|
+
// Strip shell-escape backslashes so paths like "file\ (2).p8" resolve correctly.
|
|
269
|
+
// Only on POSIX — Windows uses backslashes as path separators.
|
|
270
|
+
const normalizedPath = process.platform === 'win32' ? path : path.replace(/\\(.)/g, '$1');
|
|
271
|
+
const contents = yield* fs.readFileString(normalizedPath, 'utf8').pipe(Effect.mapError((e) => new BadArgsError({
|
|
272
|
+
message: `Could not read private key file at ${normalizedPath}: ${e.message}`,
|
|
273
|
+
})));
|
|
274
|
+
const trimmed = contents.trim();
|
|
275
|
+
if (!trimmed) {
|
|
276
|
+
return yield* BadArgsError.make({
|
|
277
|
+
message: `Private key file at ${normalizedPath} is empty.`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return trimmed;
|
|
281
|
+
});
|
|
282
|
+
const handleAppleClient = Effect.fn(function* (opts) {
|
|
283
|
+
const { yes } = yield* GlobalOpts;
|
|
284
|
+
const { auth, provider } = yield* getOrCreateProvider('apple');
|
|
285
|
+
const usedClientNames = new Set((auth.oauth_clients ?? []).map((client) => client.client_name));
|
|
286
|
+
const suggestedClientName = findName('apple', usedClientNames);
|
|
287
|
+
const clientName = yield* optOrPrompt(opts.name, {
|
|
288
|
+
simpleName: '--name',
|
|
289
|
+
required: true,
|
|
290
|
+
skipIf: false,
|
|
291
|
+
prompt: {
|
|
292
|
+
prompt: 'Client Name:',
|
|
293
|
+
defaultValue: suggestedClientName,
|
|
294
|
+
placeholder: suggestedClientName,
|
|
295
|
+
validate: validateRequired,
|
|
296
|
+
modifyOutput: UI.modifiers.piped([UI.modifiers.dimOnComplete]),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
if (usedClientNames.has(clientName || '')) {
|
|
300
|
+
return yield* BadArgsError.make({
|
|
301
|
+
message: `The unique name '${clientName}' is already in use.`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const servicesId = yield* optOrPrompt(opts['services-id'], {
|
|
305
|
+
simpleName: '--services-id',
|
|
306
|
+
required: true,
|
|
307
|
+
skipIf: false,
|
|
308
|
+
prompt: {
|
|
309
|
+
prompt: `Services ID ${chalk.dim('(from https://developer.apple.com/account/resources/identifiers/list/serviceId)')}`,
|
|
310
|
+
modifyOutput: UI.modifiers.piped([
|
|
311
|
+
UI.modifiers.topPadding,
|
|
312
|
+
UI.modifiers.dimOnComplete,
|
|
313
|
+
]),
|
|
314
|
+
validate: validateRequired,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
// If any web-flow flag is provided, enable web flow; otherwise ask
|
|
318
|
+
// (non-interactively with --yes we default to native-only).
|
|
319
|
+
const anyWebFlagProvided = Boolean(opts['team-id'] || opts['key-id'] || opts['private-key-file']);
|
|
320
|
+
const configureWeb = anyWebFlagProvided
|
|
321
|
+
? true
|
|
322
|
+
: yes
|
|
323
|
+
? false
|
|
324
|
+
: yield* optOrPromptBoolean(undefined, {
|
|
325
|
+
simpleName: '--configure-web',
|
|
326
|
+
required: false,
|
|
327
|
+
skipIf: false,
|
|
328
|
+
prompt: {
|
|
329
|
+
promptText: 'Configure web redirect flow? ' +
|
|
330
|
+
chalk.dim('(requires Team ID, Key ID, and a .p8 private key from Apple)'),
|
|
331
|
+
defaultValue: false,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const skipWeb = !configureWeb;
|
|
335
|
+
const webSkipMessage = 'requires configuring the web redirect flow (also provide --team-id, --key-id, and --private-key-file).';
|
|
336
|
+
const teamId = yield* optOrPrompt(opts['team-id'], {
|
|
337
|
+
simpleName: '--team-id',
|
|
338
|
+
required: true,
|
|
339
|
+
skipIf: skipWeb,
|
|
340
|
+
skipMessage: `--team-id ${webSkipMessage}`,
|
|
341
|
+
prompt: {
|
|
342
|
+
prompt: `Team ID ${chalk.dim('(from https://developer.apple.com/account#MembershipDetailsCard)')}`,
|
|
343
|
+
validate: validateRequired,
|
|
344
|
+
modifyOutput: UI.modifiers.piped([
|
|
345
|
+
UI.modifiers.topPadding,
|
|
346
|
+
UI.modifiers.dimOnComplete,
|
|
347
|
+
]),
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
const keyId = yield* optOrPrompt(opts['key-id'], {
|
|
351
|
+
simpleName: '--key-id',
|
|
352
|
+
required: true,
|
|
353
|
+
skipIf: skipWeb,
|
|
354
|
+
skipMessage: `--key-id ${webSkipMessage}`,
|
|
355
|
+
prompt: {
|
|
356
|
+
prompt: `Key ID ${chalk.dim('(from https://developer.apple.com/account/resources/authkeys/list)')}`,
|
|
357
|
+
validate: validateRequired,
|
|
358
|
+
modifyOutput: UI.modifiers.piped([
|
|
359
|
+
UI.modifiers.topPadding,
|
|
360
|
+
UI.modifiers.dimOnComplete,
|
|
361
|
+
]),
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
const privateKeyPath = yield* optOrPrompt(opts['private-key-file'], {
|
|
365
|
+
simpleName: '--private-key-file',
|
|
366
|
+
required: true,
|
|
367
|
+
skipIf: skipWeb,
|
|
368
|
+
skipMessage: `--private-key-file ${webSkipMessage}`,
|
|
369
|
+
prompt: {
|
|
370
|
+
prompt: `Path to .p8 private key file ${chalk.dim('(downloaded from Apple)')}`,
|
|
371
|
+
validate: validateRequired,
|
|
372
|
+
modifyOutput: UI.modifiers.piped([
|
|
373
|
+
UI.modifiers.topPadding,
|
|
374
|
+
UI.modifiers.dimOnComplete,
|
|
375
|
+
]),
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
const privateKey = privateKeyPath
|
|
379
|
+
? yield* readPrivateKeyFile(privateKeyPath)
|
|
380
|
+
: undefined;
|
|
381
|
+
const customRedirectUri = yield* optOrPrompt(opts['custom-redirect-uri'], {
|
|
382
|
+
required: false,
|
|
383
|
+
simpleName: '--custom-redirect-uri',
|
|
384
|
+
skipIf: skipWeb,
|
|
385
|
+
skipMessage: `--custom-redirect-uri ${webSkipMessage}`,
|
|
386
|
+
prompt: {
|
|
387
|
+
prompt: '',
|
|
388
|
+
placeholder: 'https://yoursite.com/oauth/callback',
|
|
389
|
+
modifyOutput: UI.modifiers.piped([
|
|
390
|
+
(output, status) => {
|
|
391
|
+
if (status === 'idle') {
|
|
392
|
+
return (`\nCustom redirect URI (optional):
|
|
393
|
+
${chalk.dim('With a custom redirect URI, users will see "Redirecting to yoursite.com..." for a more branded experience.')}
|
|
394
|
+
${chalk.dim('Your URI must forward to https://api.instantdb.com/runtime/oauth/callback with all query parameters preserved.')}\n\n` +
|
|
395
|
+
stripFirstBlankLine(output));
|
|
396
|
+
}
|
|
397
|
+
return `\nCustom redirect URI (optional):\n${stripFirstBlankLine(output)}`;
|
|
398
|
+
},
|
|
399
|
+
UI.modifiers.dimOnComplete,
|
|
400
|
+
]),
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
if (!clientName) {
|
|
404
|
+
return yield* BadArgsError.make({ message: 'Client name is required.' });
|
|
405
|
+
}
|
|
406
|
+
const redirectUri = privateKey
|
|
407
|
+
? customRedirectUri || APPLE_DEFAULT_CALLBACK_URL
|
|
408
|
+
: undefined;
|
|
409
|
+
const meta = {};
|
|
410
|
+
if (teamId !== undefined)
|
|
411
|
+
meta.teamId = teamId;
|
|
412
|
+
if (keyId !== undefined)
|
|
413
|
+
meta.keyId = keyId;
|
|
414
|
+
const response = yield* addOAuthClient({
|
|
415
|
+
providerId: provider.id,
|
|
416
|
+
clientName,
|
|
417
|
+
clientId: servicesId,
|
|
418
|
+
clientSecret: privateKey,
|
|
419
|
+
authorizationEndpoint: APPLE_AUTHORIZATION_ENDPOINT,
|
|
420
|
+
tokenEndpoint: APPLE_TOKEN_ENDPOINT,
|
|
421
|
+
discoveryEndpoint: APPLE_DISCOVERY_ENDPOINT,
|
|
422
|
+
redirectTo: redirectUri,
|
|
423
|
+
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
|
424
|
+
});
|
|
425
|
+
const summaryLines = [
|
|
426
|
+
`Apple OAuth client created: ${response.client.client_name}`,
|
|
427
|
+
`ID: ${response.client.id}`,
|
|
428
|
+
`Services ID: ${response.client.client_id ?? servicesId}`,
|
|
429
|
+
];
|
|
430
|
+
if (privateKey) {
|
|
431
|
+
summaryLines.push(`Team ID: ${teamId}`);
|
|
432
|
+
summaryLines.push(`Key ID: ${keyId}`);
|
|
433
|
+
summaryLines.push(chalk.bold(`\nAdd this return URL under your Services ID on developer.apple.com:\n${redirectUri}\n`));
|
|
434
|
+
if (customRedirectUri) {
|
|
435
|
+
summaryLines.push(`Your custom redirect must forward to ${chalk.bold(APPLE_DEFAULT_CALLBACK_URL)} with all query parameters preserved.`);
|
|
436
|
+
summaryLines.push(`You can test it by visiting: ${chalk.bold(redirectUri + '?test-redirect=true')}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
yield* Effect.log(boxen(summaryLines.join('\n'), {
|
|
440
|
+
dimBorder: true,
|
|
441
|
+
padding: { right: 1, left: 1 },
|
|
442
|
+
}));
|
|
443
|
+
});
|
|
265
444
|
export const authClientAddCmd = Effect.fn(function* (opts) {
|
|
266
445
|
const { yes } = yield* GlobalOpts;
|
|
267
446
|
if (!opts.type && yes) {
|
|
@@ -273,8 +452,8 @@ export const authClientAddCmd = Effect.fn(function* (opts) {
|
|
|
273
452
|
options: [
|
|
274
453
|
{ label: 'Google', value: 'google' },
|
|
275
454
|
{ label: 'GitHub', value: 'github' },
|
|
455
|
+
{ label: 'Apple', value: 'apple' },
|
|
276
456
|
// TODO: implement
|
|
277
|
-
// { label: 'Apple', value: 'apple' },
|
|
278
457
|
// { label: 'LinkedIn', value: 'linkedin' },
|
|
279
458
|
// { label: 'Clerk', value: 'clerk' },
|
|
280
459
|
// { label: 'Firebase', value: 'firebase' },
|
|
@@ -282,10 +461,9 @@ export const authClientAddCmd = Effect.fn(function* (opts) {
|
|
|
282
461
|
promptText: 'Select a client type:',
|
|
283
462
|
modifyOutput: UI.modifiers.piped([UI.modifiers.dimOnComplete]),
|
|
284
463
|
}))), Effect.andThen((s) => Schema.decodeUnknown(ClientTypeSchema)(s)), Effect.catchTag('ParseError', () => BadArgsError.make({
|
|
285
|
-
message:
|
|
464
|
+
message: `Invalid client type, must be one of: ${ClientTypeSchema.literals.join(', ')}`,
|
|
286
465
|
})));
|
|
287
|
-
yield* Match.value(clientType).pipe(Match.withReturnType(), Match.when('google', () => handleGoogleClient(opts)), Match.when('github', () => handleGithubClient(opts)),
|
|
288
|
-
// Match.when('apple', () => Effect.logError('Not Implemented')),
|
|
466
|
+
yield* Match.value(clientType).pipe(Match.withReturnType(), Match.when('google', () => handleGoogleClient(opts)), Match.when('github', () => handleGithubClient(opts)), Match.when('apple', () => handleAppleClient(opts)),
|
|
289
467
|
// Match.when('clerk', () => Effect.logError('Not Implemented')),
|
|
290
468
|
// Match.when('firebase', () => Effect.logError('Not Implemented')),
|
|
291
469
|
// Match.when('linkedin', () => Effect.logError('Not Implemented')),
|