@ollie-shop/cli 0.3.4 → 1.0.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.
- package/.turbo/turbo-build.log +6 -9
- package/CHANGELOG.md +21 -0
- package/dist/index.js +986 -3956
- package/package.json +15 -37
- package/src/README.md +126 -0
- package/src/cli.tsx +45 -0
- package/src/commands/help.tsx +79 -0
- package/src/commands/login.tsx +92 -0
- package/src/commands/start.tsx +411 -0
- package/src/index.tsx +8 -0
- package/src/utils/auth.ts +218 -21
- package/src/utils/bundle.ts +177 -0
- package/src/utils/config.ts +123 -0
- package/src/utils/esbuild.ts +533 -0
- package/tsconfig.json +10 -15
- package/tsup.config.ts +7 -7
- package/CLAUDE_CLI.md +0 -265
- package/README.md +0 -711
- package/__tests__/mocks/console.ts +0 -22
- package/__tests__/mocks/core.ts +0 -137
- package/__tests__/mocks/index.ts +0 -4
- package/__tests__/mocks/inquirer.ts +0 -16
- package/__tests__/mocks/progress.ts +0 -19
- package/dist/index.d.ts +0 -1
- package/src/__tests__/helpers/cli-test-helper.ts +0 -281
- package/src/__tests__/mocks/index.ts +0 -142
- package/src/actions/component.actions.ts +0 -278
- package/src/actions/function.actions.ts +0 -220
- package/src/actions/project.actions.ts +0 -131
- package/src/actions/version.actions.ts +0 -233
- package/src/commands/__tests__/component-validation.test.ts +0 -250
- package/src/commands/__tests__/component.test.ts +0 -318
- package/src/commands/__tests__/function-validation.test.ts +0 -220
- package/src/commands/__tests__/function.test.ts +0 -286
- package/src/commands/__tests__/store-version-validation.test.ts +0 -414
- package/src/commands/__tests__/store-version.test.ts +0 -402
- package/src/commands/component.ts +0 -178
- package/src/commands/docs.ts +0 -24
- package/src/commands/function.ts +0 -201
- package/src/commands/help.ts +0 -18
- package/src/commands/index.ts +0 -27
- package/src/commands/login.ts +0 -267
- package/src/commands/project.ts +0 -107
- package/src/commands/store-version.ts +0 -242
- package/src/commands/version.ts +0 -51
- package/src/commands/whoami.ts +0 -46
- package/src/index.ts +0 -116
- package/src/prompts/component.prompts.ts +0 -94
- package/src/prompts/function.prompts.ts +0 -168
- package/src/schemas/command.schema.ts +0 -644
- package/src/types/index.ts +0 -183
- package/src/utils/__tests__/command-parser.test.ts +0 -159
- package/src/utils/__tests__/command-suggestions.test.ts +0 -185
- package/src/utils/__tests__/console.test.ts +0 -192
- package/src/utils/__tests__/context-detector.test.ts +0 -258
- package/src/utils/__tests__/enhanced-error-handler.test.ts +0 -137
- package/src/utils/__tests__/error-handler.test.ts +0 -107
- package/src/utils/__tests__/rich-progress.test.ts +0 -181
- package/src/utils/__tests__/validation-error-formatter.test.ts +0 -175
- package/src/utils/__tests__/validation-helpers.test.ts +0 -125
- package/src/utils/cli-progress-reporter.ts +0 -84
- package/src/utils/command-builder.ts +0 -390
- package/src/utils/command-helpers.ts +0 -83
- package/src/utils/command-parser.ts +0 -245
- package/src/utils/command-suggestions.ts +0 -176
- package/src/utils/console.ts +0 -320
- package/src/utils/constants.ts +0 -39
- package/src/utils/context-detector.ts +0 -177
- package/src/utils/deploy-helpers.ts +0 -357
- package/src/utils/enhanced-error-handler.ts +0 -264
- package/src/utils/error-handler.ts +0 -60
- package/src/utils/errors.ts +0 -256
- package/src/utils/interactive-builder.ts +0 -325
- package/src/utils/rich-progress.ts +0 -331
- package/src/utils/store.ts +0 -23
- package/src/utils/validation-error-formatter.ts +0 -337
- package/src/utils/validation-helpers.ts +0 -325
- package/vitest.config.ts +0 -35
- package/vitest.setup.ts +0 -29
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import type { BuildContext, ServeOnRequestArgs } from "esbuild";
|
|
2
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { loadConfig, resolveStage } from "../utils/config.js";
|
|
6
|
+
import {
|
|
7
|
+
type ComponentInfo,
|
|
8
|
+
createBuildContext,
|
|
9
|
+
discoverComponents,
|
|
10
|
+
startDevServer,
|
|
11
|
+
} from "../utils/esbuild.js";
|
|
12
|
+
|
|
13
|
+
interface StartCommandProps {
|
|
14
|
+
args: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RequestLog {
|
|
18
|
+
id: number;
|
|
19
|
+
method: string;
|
|
20
|
+
path: string;
|
|
21
|
+
status: number;
|
|
22
|
+
time: number;
|
|
23
|
+
timestamp: Date;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ServerState =
|
|
27
|
+
| { status: "initializing" }
|
|
28
|
+
| { status: "discovering" }
|
|
29
|
+
| { status: "building" }
|
|
30
|
+
| {
|
|
31
|
+
status: "running";
|
|
32
|
+
host: string;
|
|
33
|
+
port: number;
|
|
34
|
+
storeId: string;
|
|
35
|
+
versionId?: string;
|
|
36
|
+
}
|
|
37
|
+
| { status: "error"; message: string };
|
|
38
|
+
|
|
39
|
+
const STUDIO_BASE_URL = "https://admin.ollie.shop/studio";
|
|
40
|
+
|
|
41
|
+
const MAX_LOGS = 10;
|
|
42
|
+
|
|
43
|
+
const PORT = 4000;
|
|
44
|
+
|
|
45
|
+
function parseArg(args: string[], ...flags: string[]): string | undefined {
|
|
46
|
+
const index = args.findIndex((a) => flags.includes(a));
|
|
47
|
+
return index !== -1 ? args[index + 1] : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function StartCommand({ args }: StartCommandProps) {
|
|
51
|
+
const { exit } = useApp();
|
|
52
|
+
const [state, setState] = useState<ServerState>({ status: "initializing" });
|
|
53
|
+
const [components, setComponents] = useState<ComponentInfo[]>([]);
|
|
54
|
+
const [logs, setLogs] = useState<RequestLog[]>([]);
|
|
55
|
+
const [buildCount, setBuildCount] = useState(0);
|
|
56
|
+
const [lastBuildTime, setLastBuildTime] = useState<Date | null>(null);
|
|
57
|
+
const logIdRef = useRef(0);
|
|
58
|
+
const ctxRef = useRef<BuildContext | null>(null);
|
|
59
|
+
const stopRef = useRef<(() => Promise<void>) | null>(null);
|
|
60
|
+
|
|
61
|
+
// Parse args
|
|
62
|
+
const stage = resolveStage(parseArg(args, "--stage", "-s"));
|
|
63
|
+
|
|
64
|
+
const addLog = useCallback((log: Omit<RequestLog, "id" | "timestamp">) => {
|
|
65
|
+
setLogs((prev) => {
|
|
66
|
+
const newLog = {
|
|
67
|
+
...log,
|
|
68
|
+
id: ++logIdRef.current,
|
|
69
|
+
timestamp: new Date(),
|
|
70
|
+
};
|
|
71
|
+
return [...prev.slice(-(MAX_LOGS - 1)), newLog];
|
|
72
|
+
});
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const handleRequest = useCallback(
|
|
76
|
+
(args: ServeOnRequestArgs) => {
|
|
77
|
+
addLog({
|
|
78
|
+
method: args.method,
|
|
79
|
+
path: args.path,
|
|
80
|
+
status: args.status,
|
|
81
|
+
time: args.timeInMS,
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
[addLog],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Initialize server
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
let mounted = true;
|
|
90
|
+
|
|
91
|
+
async function init() {
|
|
92
|
+
try {
|
|
93
|
+
// Check config
|
|
94
|
+
const config = await loadConfig({ stage });
|
|
95
|
+
if (!config?.storeId) {
|
|
96
|
+
const configFile =
|
|
97
|
+
stage && stage !== "prod" ? `ollie.${stage}.json` : "ollie.json";
|
|
98
|
+
setState({
|
|
99
|
+
status: "error",
|
|
100
|
+
message: `No ${configFile} found or storeId is missing. Run this command in a project directory.`,
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!mounted) return;
|
|
106
|
+
setState({ status: "discovering" });
|
|
107
|
+
|
|
108
|
+
// Discover components
|
|
109
|
+
const found = await discoverComponents({ stage });
|
|
110
|
+
if (!mounted) return;
|
|
111
|
+
|
|
112
|
+
if (found.length === 0) {
|
|
113
|
+
setState({
|
|
114
|
+
status: "error",
|
|
115
|
+
message:
|
|
116
|
+
"No components found. Create components in ./components/<name>/index.tsx",
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setComponents(found);
|
|
122
|
+
setState({ status: "building" });
|
|
123
|
+
|
|
124
|
+
// Create build context with discovered components
|
|
125
|
+
const ctx = await createBuildContext(found, {
|
|
126
|
+
stage,
|
|
127
|
+
onBuildEnd: (updatedComponents) => {
|
|
128
|
+
setComponents(updatedComponents);
|
|
129
|
+
setBuildCount((c) => c + 1);
|
|
130
|
+
setLastBuildTime(new Date());
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
ctxRef.current = ctx;
|
|
134
|
+
|
|
135
|
+
// Do initial build (manifest is written by the plugin)
|
|
136
|
+
await ctx.rebuild();
|
|
137
|
+
|
|
138
|
+
if (!mounted) return;
|
|
139
|
+
|
|
140
|
+
// Start dev server
|
|
141
|
+
const server = await startDevServer(ctx, {
|
|
142
|
+
port: PORT,
|
|
143
|
+
onRequest: handleRequest,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
stopRef.current = server.stop;
|
|
147
|
+
|
|
148
|
+
if (!mounted) {
|
|
149
|
+
await server.stop();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setState({
|
|
154
|
+
status: "running",
|
|
155
|
+
host: server.host,
|
|
156
|
+
port: server.port,
|
|
157
|
+
storeId: config.storeId,
|
|
158
|
+
versionId: config.versionId,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Open Studio in browser
|
|
162
|
+
const studioUrl = new URL(STUDIO_BASE_URL);
|
|
163
|
+
studioUrl.searchParams.set("storeId", config.storeId);
|
|
164
|
+
if (config.versionId) {
|
|
165
|
+
studioUrl.searchParams.set("versionId", config.versionId);
|
|
166
|
+
}
|
|
167
|
+
open(studioUrl.toString());
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (!mounted) return;
|
|
170
|
+
setState({
|
|
171
|
+
status: "error",
|
|
172
|
+
message: error instanceof Error ? error.message : "Unknown error",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
init();
|
|
178
|
+
|
|
179
|
+
return () => {
|
|
180
|
+
mounted = false;
|
|
181
|
+
stopRef.current?.();
|
|
182
|
+
};
|
|
183
|
+
}, [stage, handleRequest]);
|
|
184
|
+
|
|
185
|
+
// Handle keyboard input
|
|
186
|
+
useInput((input, key) => {
|
|
187
|
+
if (input === "q" || (input === "c" && key.ctrl)) {
|
|
188
|
+
stopRef.current?.().then(() => exit());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Manual rebuild with 'r' (manifest is updated by the plugin)
|
|
192
|
+
if (input === "r" && state.status === "running") {
|
|
193
|
+
ctxRef.current?.rebuild();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Open Studio in browser with 'o'
|
|
197
|
+
if (input === "o" && state.status === "running") {
|
|
198
|
+
const studioUrl = new URL(STUDIO_BASE_URL);
|
|
199
|
+
studioUrl.searchParams.set("storeId", state.storeId);
|
|
200
|
+
if (state.versionId) {
|
|
201
|
+
studioUrl.searchParams.set("versionId", state.versionId);
|
|
202
|
+
}
|
|
203
|
+
open(studioUrl.toString());
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Box flexDirection="column" gap={1}>
|
|
209
|
+
<Header />
|
|
210
|
+
|
|
211
|
+
{state.status === "initializing" && (
|
|
212
|
+
<Box>
|
|
213
|
+
<Text color="yellow">⏳ </Text>
|
|
214
|
+
<Text>Initializing...</Text>
|
|
215
|
+
</Box>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{state.status === "discovering" && (
|
|
219
|
+
<Box>
|
|
220
|
+
<Text color="yellow">🔍 </Text>
|
|
221
|
+
<Text>Discovering components...</Text>
|
|
222
|
+
</Box>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{state.status === "building" && (
|
|
226
|
+
<Box>
|
|
227
|
+
<Text color="yellow">🔨 </Text>
|
|
228
|
+
<Text>Building {components.length} component(s)...</Text>
|
|
229
|
+
</Box>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{state.status === "error" && (
|
|
233
|
+
<Box flexDirection="column" gap={1}>
|
|
234
|
+
<Box>
|
|
235
|
+
<Text color="red">✗ </Text>
|
|
236
|
+
<Text color="red">{state.message}</Text>
|
|
237
|
+
</Box>
|
|
238
|
+
</Box>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{state.status === "running" && (
|
|
242
|
+
<>
|
|
243
|
+
<ServerInfo
|
|
244
|
+
host={state.host}
|
|
245
|
+
port={state.port}
|
|
246
|
+
stage={stage}
|
|
247
|
+
storeId={state.storeId}
|
|
248
|
+
versionId={state.versionId}
|
|
249
|
+
/>
|
|
250
|
+
<ComponentList components={components} />
|
|
251
|
+
<BuildInfo buildCount={buildCount} lastBuildTime={lastBuildTime} />
|
|
252
|
+
<RequestLogs logs={logs} />
|
|
253
|
+
<Footer />
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
</Box>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function Header() {
|
|
261
|
+
return (
|
|
262
|
+
<Box borderStyle="round" borderColor="cyan" paddingX={2}>
|
|
263
|
+
<Text bold color="cyan">
|
|
264
|
+
Ollie Studio
|
|
265
|
+
</Text>
|
|
266
|
+
<Text dimColor> - Development Server</Text>
|
|
267
|
+
</Box>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ServerInfo({
|
|
272
|
+
host,
|
|
273
|
+
port,
|
|
274
|
+
stage,
|
|
275
|
+
storeId,
|
|
276
|
+
versionId,
|
|
277
|
+
}: {
|
|
278
|
+
host: string;
|
|
279
|
+
port: number;
|
|
280
|
+
stage?: string;
|
|
281
|
+
storeId: string;
|
|
282
|
+
versionId?: string;
|
|
283
|
+
}) {
|
|
284
|
+
const studioUrl = new URL(STUDIO_BASE_URL);
|
|
285
|
+
|
|
286
|
+
studioUrl.searchParams.set("storeId", storeId);
|
|
287
|
+
if (versionId) {
|
|
288
|
+
studioUrl.searchParams.set("versionId", versionId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<Box flexDirection="column">
|
|
293
|
+
<Box>
|
|
294
|
+
<Text color="green">✓ </Text>
|
|
295
|
+
<Text>Server running at </Text>
|
|
296
|
+
<Text bold color="cyan">
|
|
297
|
+
http://{host}:{port}
|
|
298
|
+
</Text>
|
|
299
|
+
{stage && (
|
|
300
|
+
<>
|
|
301
|
+
<Text> </Text>
|
|
302
|
+
<Text dimColor>[stage: </Text>
|
|
303
|
+
<Text color="yellow">{stage}</Text>
|
|
304
|
+
<Text dimColor>]</Text>
|
|
305
|
+
</>
|
|
306
|
+
)}
|
|
307
|
+
</Box>
|
|
308
|
+
<Box marginTop={1}>
|
|
309
|
+
<Text color="green">✓ </Text>
|
|
310
|
+
<Text>Studio: </Text>
|
|
311
|
+
<Text bold color="magenta">
|
|
312
|
+
{studioUrl.toString()}
|
|
313
|
+
</Text>
|
|
314
|
+
</Box>
|
|
315
|
+
<Box marginLeft={2} marginTop={1}>
|
|
316
|
+
<Text dimColor>
|
|
317
|
+
Components: http://{host}:{port}/{"<name>"}/index.js
|
|
318
|
+
</Text>
|
|
319
|
+
</Box>
|
|
320
|
+
<Box marginLeft={2}>
|
|
321
|
+
<Text dimColor>
|
|
322
|
+
Events: http://{host}:{port}/esbuild
|
|
323
|
+
</Text>
|
|
324
|
+
</Box>
|
|
325
|
+
<Box marginLeft={2}>
|
|
326
|
+
<Text dimColor>
|
|
327
|
+
Bundle: http://{host}:{port}/bundle?path=/{"<name>"}/index.js
|
|
328
|
+
</Text>
|
|
329
|
+
</Box>
|
|
330
|
+
</Box>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function ComponentList({ components }: { components: ComponentInfo[] }) {
|
|
335
|
+
return (
|
|
336
|
+
<Box flexDirection="column" marginTop={1}>
|
|
337
|
+
<Text bold>Components ({components.length}):</Text>
|
|
338
|
+
<Box marginLeft={2} flexDirection="column">
|
|
339
|
+
{components.map((c) => (
|
|
340
|
+
<Box key={c.name}>
|
|
341
|
+
<Text color="green">• </Text>
|
|
342
|
+
<Text>{c.name}</Text>
|
|
343
|
+
<Text dimColor> → /{c.name}/index.js</Text>
|
|
344
|
+
</Box>
|
|
345
|
+
))}
|
|
346
|
+
</Box>
|
|
347
|
+
</Box>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function BuildInfo({
|
|
352
|
+
buildCount,
|
|
353
|
+
lastBuildTime,
|
|
354
|
+
}: {
|
|
355
|
+
buildCount: number;
|
|
356
|
+
lastBuildTime: Date | null;
|
|
357
|
+
}) {
|
|
358
|
+
return (
|
|
359
|
+
<Box marginTop={1}>
|
|
360
|
+
<Text dimColor>
|
|
361
|
+
Builds: {buildCount}
|
|
362
|
+
{lastBuildTime && ` | Last: ${lastBuildTime.toLocaleTimeString()}`}
|
|
363
|
+
</Text>
|
|
364
|
+
</Box>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function RequestLogs({ logs }: { logs: RequestLog[] }) {
|
|
369
|
+
if (logs.length === 0) {
|
|
370
|
+
return (
|
|
371
|
+
<Box marginTop={1} flexDirection="column">
|
|
372
|
+
<Text bold>Request Log:</Text>
|
|
373
|
+
<Box marginLeft={2}>
|
|
374
|
+
<Text dimColor>No requests yet...</Text>
|
|
375
|
+
</Box>
|
|
376
|
+
</Box>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<Box marginTop={1} flexDirection="column">
|
|
382
|
+
<Text bold>Request Log:</Text>
|
|
383
|
+
<Box marginLeft={2} flexDirection="column">
|
|
384
|
+
{logs.map((log) => (
|
|
385
|
+
<Box key={log.id}>
|
|
386
|
+
<Text color={log.status < 400 ? "green" : "red"}>
|
|
387
|
+
{log.status}{" "}
|
|
388
|
+
</Text>
|
|
389
|
+
<Text>{log.method} </Text>
|
|
390
|
+
<Text dimColor>{log.path}</Text>
|
|
391
|
+
<Text dimColor> ({log.time}ms)</Text>
|
|
392
|
+
</Box>
|
|
393
|
+
))}
|
|
394
|
+
</Box>
|
|
395
|
+
</Box>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function Footer() {
|
|
400
|
+
return (
|
|
401
|
+
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
402
|
+
<Text dimColor>Press </Text>
|
|
403
|
+
<Text bold>q</Text>
|
|
404
|
+
<Text dimColor> to quit | </Text>
|
|
405
|
+
<Text bold>r</Text>
|
|
406
|
+
<Text dimColor> to rebuild | </Text>
|
|
407
|
+
<Text bold>o</Text>
|
|
408
|
+
<Text dimColor> to open Studio</Text>
|
|
409
|
+
</Box>
|
|
410
|
+
);
|
|
411
|
+
}
|
package/src/index.tsx
ADDED
package/src/utils/auth.ts
CHANGED
|
@@ -1,40 +1,237 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { createServer } from "node:http";
|
|
2
5
|
import { homedir } from "node:os";
|
|
3
6
|
import path from "node:path";
|
|
4
7
|
import { jwtDecode } from "jwt-decode";
|
|
5
8
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"credentials.json",
|
|
10
|
-
);
|
|
9
|
+
const AUTH_ENDPOINT = "https://admin.ollie.shop/auth/login";
|
|
10
|
+
const CONFIG_DIR = path.join(homedir(), ".ollie-shop");
|
|
11
|
+
const CREDENTIALS_PATH = path.join(CONFIG_DIR, "credentials.json");
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
export interface AuthToken {
|
|
13
14
|
accessToken: string;
|
|
14
|
-
refreshToken
|
|
15
|
-
expiresAt
|
|
16
|
-
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
email?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Credentials {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
expiresAt: string;
|
|
24
|
+
}
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
interface JwtPayload {
|
|
19
27
|
email?: string;
|
|
20
|
-
exp?: number;
|
|
21
28
|
sub?: string;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
29
|
+
exp?: number;
|
|
30
|
+
}
|
|
25
31
|
|
|
26
|
-
export async function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
export async function startWebAuthFlow(options: {
|
|
33
|
+
port: number;
|
|
34
|
+
}): Promise<AuthToken | null> {
|
|
35
|
+
const { default: open } = await import("open");
|
|
36
|
+
const state = randomBytes(16).toString("hex");
|
|
37
|
+
const { port } = options;
|
|
38
|
+
|
|
39
|
+
return new Promise<AuthToken | null>((resolve, reject) => {
|
|
40
|
+
const server = createServer(async (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
43
|
+
|
|
44
|
+
if (url.pathname === "/callback") {
|
|
45
|
+
await handleAuthCallback(req, res, state, resolve, reject, server);
|
|
46
|
+
} else {
|
|
47
|
+
sendWaitingResponse(res);
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const errorMessage =
|
|
51
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
52
|
+
sendErrorResponse(res, 500, "Server Error", errorMessage);
|
|
53
|
+
server.close(() => reject(new Error(errorMessage)));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
server.listen(port, async () => {
|
|
58
|
+
const redirectUrl = `http://localhost:${port}/callback`;
|
|
59
|
+
const authUrl = new URL(AUTH_ENDPOINT);
|
|
30
60
|
|
|
31
|
-
|
|
61
|
+
authUrl.searchParams.set("flow", "cli");
|
|
62
|
+
authUrl.searchParams.set("state", state);
|
|
63
|
+
authUrl.searchParams.set("redirect_to", redirectUrl);
|
|
32
64
|
|
|
33
|
-
|
|
65
|
+
await open(authUrl.toString());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
69
|
+
if (err.code === "EADDRINUSE") {
|
|
70
|
+
reject(
|
|
71
|
+
new Error(
|
|
72
|
+
`Port ${port} is already in use. Try --port <number> to use a different port.`,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
reject(err);
|
|
77
|
+
}
|
|
78
|
+
server.close();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 5 minute timeout
|
|
82
|
+
const timeoutId = setTimeout(
|
|
83
|
+
() => {
|
|
84
|
+
server.close(() => {
|
|
85
|
+
reject(new Error("Authentication timed out. Please try again."));
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
5 * 60 * 1000,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
server.on("close", () => {
|
|
92
|
+
clearTimeout(timeoutId);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function handleAuthCallback(
|
|
98
|
+
req: IncomingMessage,
|
|
99
|
+
res: ServerResponse,
|
|
100
|
+
state: string,
|
|
101
|
+
resolve: (token: AuthToken | null) => void,
|
|
102
|
+
reject: (err: Error) => void,
|
|
103
|
+
server: ReturnType<typeof createServer>,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const socket = req.socket as { localPort?: number };
|
|
106
|
+
const url = new URL(
|
|
107
|
+
req.url || "/",
|
|
108
|
+
`http://localhost:${socket.localPort || 3000}`,
|
|
109
|
+
);
|
|
110
|
+
const params = url.searchParams;
|
|
111
|
+
|
|
112
|
+
const returnedState = params.get("state");
|
|
113
|
+
if (returnedState !== state) {
|
|
114
|
+
sendErrorResponse(res, 400, "Invalid state parameter", "Please try again.");
|
|
115
|
+
reject(new Error("Invalid state parameter"));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let formData = "";
|
|
120
|
+
req.on("data", (chunk) => {
|
|
121
|
+
formData += chunk.toString();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await new Promise<void>((formResolve) => {
|
|
125
|
+
req.on("end", () => formResolve());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const formParams = new URLSearchParams(formData);
|
|
129
|
+
const accessToken = formParams.get("access_token");
|
|
130
|
+
const refreshToken = formParams.get("refresh_token") || "";
|
|
131
|
+
const expiresAt =
|
|
132
|
+
formParams.get("expires_at") ||
|
|
133
|
+
new Date(Date.now() + 3600000).toISOString();
|
|
134
|
+
|
|
135
|
+
if (!accessToken) {
|
|
136
|
+
sendErrorResponse(res, 400, "Missing token", "Authentication failed.");
|
|
137
|
+
reject(new Error("Missing authentication token"));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
34
140
|
|
|
35
|
-
|
|
141
|
+
try {
|
|
142
|
+
const decoded = jwtDecode<JwtPayload>(accessToken);
|
|
143
|
+
const token: AuthToken = {
|
|
144
|
+
accessToken,
|
|
145
|
+
refreshToken,
|
|
146
|
+
expiresAt,
|
|
36
147
|
email: decoded.email,
|
|
37
148
|
};
|
|
149
|
+
|
|
150
|
+
sendSuccessResponse(res);
|
|
151
|
+
server.close(() => resolve(token));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const errorMessage =
|
|
154
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
155
|
+
sendErrorResponse(res, 500, "Authentication failed", errorMessage);
|
|
156
|
+
server.close(() => reject(new Error(errorMessage)));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sendErrorResponse(
|
|
161
|
+
res: ServerResponse,
|
|
162
|
+
statusCode: number,
|
|
163
|
+
title: string,
|
|
164
|
+
message: string,
|
|
165
|
+
): void {
|
|
166
|
+
res.writeHead(statusCode, { "Content-Type": "text/html" });
|
|
167
|
+
res.end(`
|
|
168
|
+
<!DOCTYPE html>
|
|
169
|
+
<html>
|
|
170
|
+
<head><title>Ollie CLI - Error</title></head>
|
|
171
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
172
|
+
<h1 style="color: #dc2626;">${title}</h1>
|
|
173
|
+
<p>${message}</p>
|
|
174
|
+
</body>
|
|
175
|
+
</html>
|
|
176
|
+
`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sendSuccessResponse(res: ServerResponse): void {
|
|
180
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
181
|
+
res.end(`
|
|
182
|
+
<!DOCTYPE html>
|
|
183
|
+
<html>
|
|
184
|
+
<head><title>Ollie CLI - Success</title></head>
|
|
185
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
186
|
+
<h1 style="color: #16a34a;">Authentication Successful!</h1>
|
|
187
|
+
<p>You can close this window and return to the CLI.</p>
|
|
188
|
+
</body>
|
|
189
|
+
</html>
|
|
190
|
+
`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sendWaitingResponse(res: ServerResponse): void {
|
|
194
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
195
|
+
res.end(`
|
|
196
|
+
<!DOCTYPE html>
|
|
197
|
+
<html>
|
|
198
|
+
<head><title>Ollie CLI</title></head>
|
|
199
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
200
|
+
<h1>Ollie CLI Authentication</h1>
|
|
201
|
+
<p>Waiting for authentication response...</p>
|
|
202
|
+
</body>
|
|
203
|
+
</html>
|
|
204
|
+
`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function saveCredentials(token: AuthToken): Promise<void> {
|
|
208
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
209
|
+
|
|
210
|
+
const credentials: Credentials = {
|
|
211
|
+
accessToken: token.accessToken,
|
|
212
|
+
refreshToken: token.refreshToken,
|
|
213
|
+
expiresAt: token.expiresAt,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
await fs.writeFile(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function getCredentials(): Promise<Credentials | null> {
|
|
220
|
+
try {
|
|
221
|
+
const content = await fs.readFile(CREDENTIALS_PATH, "utf-8");
|
|
222
|
+
return JSON.parse(content) as Credentials;
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function getCurrentUser(): Promise<{ email: string } | null> {
|
|
229
|
+
const credentials = await getCredentials();
|
|
230
|
+
if (!credentials) return null;
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const decoded = jwtDecode<JwtPayload>(credentials.accessToken);
|
|
234
|
+
return decoded.email ? { email: decoded.email } : null;
|
|
38
235
|
} catch {
|
|
39
236
|
return null;
|
|
40
237
|
}
|