@localpulse/cli 0.0.1
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/README.md +65 -0
- package/package.json +37 -0
- package/src/index.test.ts +137 -0
- package/src/index.ts +469 -0
- package/src/lib/api-response.ts +63 -0
- package/src/lib/api-url.ts +15 -0
- package/src/lib/argv.ts +126 -0
- package/src/lib/auth.ts +9 -0
- package/src/lib/cli-read-client.test.ts +94 -0
- package/src/lib/cli-read-client.ts +97 -0
- package/src/lib/cli-read-types.ts +27 -0
- package/src/lib/credentials.test.ts +115 -0
- package/src/lib/credentials.ts +113 -0
- package/src/lib/login.test.ts +73 -0
- package/src/lib/login.ts +42 -0
- package/src/lib/output.ts +16 -0
- package/src/lib/research-reader.ts +29 -0
- package/src/lib/research-schema.test.ts +180 -0
- package/src/lib/research-schema.ts +160 -0
- package/src/lib/token.test.ts +17 -0
- package/src/lib/token.ts +8 -0
- package/src/lib/upload-client.test.ts +82 -0
- package/src/lib/upload-client.ts +353 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { CliApiError } from "./api-response";
|
|
4
|
+
import {
|
|
5
|
+
fetchCliInfo,
|
|
6
|
+
searchCliEvents,
|
|
7
|
+
} from "./cli-read-client";
|
|
8
|
+
|
|
9
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("cli-read-client", () => {
|
|
16
|
+
it("fetches CLI info over REST", async () => {
|
|
17
|
+
globalThis.fetch = mock(async (input, init) => {
|
|
18
|
+
expect(String(input)).toBe("https://localpulse.nl/api/cli/info");
|
|
19
|
+
expect(init?.method).toBe("GET");
|
|
20
|
+
return new Response(
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
version: "v1",
|
|
23
|
+
mode: "read_write",
|
|
24
|
+
surface: "rest",
|
|
25
|
+
ingest_directives_version: "2026-03-11",
|
|
26
|
+
links: {
|
|
27
|
+
onboarding_url: "https://localpulse.nl/dev",
|
|
28
|
+
website_url: "https://localpulse.nl/",
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
32
|
+
);
|
|
33
|
+
}) as typeof fetch;
|
|
34
|
+
|
|
35
|
+
expect(await fetchCliInfo("https://localpulse.nl")).toMatchObject({
|
|
36
|
+
version: "v1",
|
|
37
|
+
mode: "read_write",
|
|
38
|
+
surface: "rest",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("encodes search query params", async () => {
|
|
43
|
+
globalThis.fetch = mock(async (input) => {
|
|
44
|
+
expect(String(input)).toBe(
|
|
45
|
+
"https://localpulse.nl/api/cli/events/search?query=amsterdam&limit=10&cursor=2&city=Amsterdam&time_intent=today&timezone=Europe%2FAmsterdam",
|
|
46
|
+
);
|
|
47
|
+
return new Response(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
results: [],
|
|
50
|
+
cursor: 2,
|
|
51
|
+
next_cursor: null,
|
|
52
|
+
}),
|
|
53
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
54
|
+
);
|
|
55
|
+
}) as typeof fetch;
|
|
56
|
+
|
|
57
|
+
expect(
|
|
58
|
+
await searchCliEvents("https://localpulse.nl", {
|
|
59
|
+
query: "amsterdam",
|
|
60
|
+
city: "Amsterdam",
|
|
61
|
+
time_intent: "today",
|
|
62
|
+
timezone: "Europe/Amsterdam",
|
|
63
|
+
limit: 10,
|
|
64
|
+
cursor: 2,
|
|
65
|
+
}),
|
|
66
|
+
).toEqual({
|
|
67
|
+
results: [],
|
|
68
|
+
cursor: 2,
|
|
69
|
+
next_cursor: null,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("surfaces REST error responses", async () => {
|
|
74
|
+
globalThis.fetch = mock(async () => {
|
|
75
|
+
return new Response(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
detail: "Search failed",
|
|
78
|
+
}),
|
|
79
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
80
|
+
);
|
|
81
|
+
}) as typeof fetch;
|
|
82
|
+
|
|
83
|
+
await expect(
|
|
84
|
+
searchCliEvents("https://localpulse.nl", {
|
|
85
|
+
query: "test",
|
|
86
|
+
limit: 10,
|
|
87
|
+
cursor: 0,
|
|
88
|
+
}),
|
|
89
|
+
).rejects.toMatchObject({
|
|
90
|
+
message: "Search failed",
|
|
91
|
+
httpStatus: 500,
|
|
92
|
+
} satisfies Partial<CliApiError>);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CliApiError,
|
|
3
|
+
type ApiResponseBody,
|
|
4
|
+
extractApiErrorMessage,
|
|
5
|
+
parseApiJsonBody,
|
|
6
|
+
} from "./api-response";
|
|
7
|
+
import { buildApiUrl } from "./api-url";
|
|
8
|
+
import type {
|
|
9
|
+
CliInfoResult,
|
|
10
|
+
SearchEventsResult,
|
|
11
|
+
} from "./cli-read-types";
|
|
12
|
+
|
|
13
|
+
export async function fetchCliInfo(apiUrl: string): Promise<CliInfoResult> {
|
|
14
|
+
const payload = await getJson(apiUrl, "/api/cli/info", "CLI info failed");
|
|
15
|
+
return parseCliInfoResult(payload);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function searchCliEvents(
|
|
19
|
+
apiUrl: string,
|
|
20
|
+
args: {
|
|
21
|
+
query: string;
|
|
22
|
+
city?: string;
|
|
23
|
+
time_intent?: "today" | "weekend";
|
|
24
|
+
timezone?: string;
|
|
25
|
+
limit: number;
|
|
26
|
+
cursor: number;
|
|
27
|
+
},
|
|
28
|
+
): Promise<SearchEventsResult> {
|
|
29
|
+
const search = new URLSearchParams({
|
|
30
|
+
query: args.query,
|
|
31
|
+
limit: String(args.limit),
|
|
32
|
+
cursor: String(args.cursor),
|
|
33
|
+
});
|
|
34
|
+
if (args.city) {
|
|
35
|
+
search.set("city", args.city);
|
|
36
|
+
}
|
|
37
|
+
if (args.time_intent) {
|
|
38
|
+
search.set("time_intent", args.time_intent);
|
|
39
|
+
}
|
|
40
|
+
if (args.timezone) {
|
|
41
|
+
search.set("timezone", args.timezone);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const payload = await getJson(
|
|
45
|
+
apiUrl,
|
|
46
|
+
`/api/cli/events/search?${search.toString()}`,
|
|
47
|
+
"CLI search failed",
|
|
48
|
+
);
|
|
49
|
+
return parseSearchEventsResult(payload);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getJson(
|
|
53
|
+
apiUrl: string,
|
|
54
|
+
path: string,
|
|
55
|
+
fallbackMessage: string,
|
|
56
|
+
): Promise<ApiResponseBody> {
|
|
57
|
+
const response = await fetch(buildApiUrl(apiUrl, path), {
|
|
58
|
+
method: "GET",
|
|
59
|
+
headers: {
|
|
60
|
+
accept: "application/json",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const payload = await parseApiJsonBody(response);
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new CliApiError(extractApiErrorMessage(payload, `${fallbackMessage} (${response.status})`), {
|
|
67
|
+
httpStatus: response.status,
|
|
68
|
+
body: payload,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return payload;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseCliInfoResult(payload: ApiResponseBody): CliInfoResult {
|
|
75
|
+
const result = payload as Partial<CliInfoResult>;
|
|
76
|
+
if (
|
|
77
|
+
typeof result.version !== "string" ||
|
|
78
|
+
result.mode !== "read_write" ||
|
|
79
|
+
result.surface !== "rest" ||
|
|
80
|
+
typeof result.ingest_directives_version !== "string"
|
|
81
|
+
) {
|
|
82
|
+
throw new Error("Invalid API response: expected CLI info payload.");
|
|
83
|
+
}
|
|
84
|
+
return result as CliInfoResult;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseSearchEventsResult(payload: ApiResponseBody): SearchEventsResult {
|
|
88
|
+
const result = payload as Partial<SearchEventsResult>;
|
|
89
|
+
if (
|
|
90
|
+
!Array.isArray(result.results) ||
|
|
91
|
+
typeof result.cursor !== "number" ||
|
|
92
|
+
(typeof result.next_cursor !== "number" && result.next_cursor !== null)
|
|
93
|
+
) {
|
|
94
|
+
throw new Error("Invalid API response: expected CLI search payload.");
|
|
95
|
+
}
|
|
96
|
+
return result as SearchEventsResult;
|
|
97
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type CliInfoResult = {
|
|
2
|
+
version: string;
|
|
3
|
+
mode: "read_write";
|
|
4
|
+
surface: "rest";
|
|
5
|
+
ingest_directives_version: string;
|
|
6
|
+
links: {
|
|
7
|
+
onboarding_url: string;
|
|
8
|
+
website_url: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SearchEventCard = {
|
|
13
|
+
poster_id: string;
|
|
14
|
+
title: string | null;
|
|
15
|
+
event_datetime: string | null;
|
|
16
|
+
location: string | null;
|
|
17
|
+
venue_name: string | null;
|
|
18
|
+
venue_city: string | null;
|
|
19
|
+
genres: string[] | null;
|
|
20
|
+
frontend_url: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SearchEventsResult = {
|
|
24
|
+
results: SearchEventCard[];
|
|
25
|
+
cursor: number;
|
|
26
|
+
next_cursor: number | null;
|
|
27
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
deleteCredentials,
|
|
7
|
+
getCredentialsPath,
|
|
8
|
+
loadStoredCredentials,
|
|
9
|
+
resolveApiUrl,
|
|
10
|
+
saveCredentials,
|
|
11
|
+
} from "./credentials";
|
|
12
|
+
import { normalizeApiUrl } from "./api-url";
|
|
13
|
+
|
|
14
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
process.env = { ...ORIGINAL_ENV };
|
|
18
|
+
await Bun.file(getCredentialsPath()).delete().catch(() => undefined);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("credentials", () => {
|
|
22
|
+
it("normalizes api urls", () => {
|
|
23
|
+
expect(normalizeApiUrl("https://localpulse.nl///")).toBe("https://localpulse.nl");
|
|
24
|
+
expect(normalizeApiUrl(undefined)).toBe("https://localpulse.nl");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("persists and reloads stored credentials", async () => {
|
|
28
|
+
await saveCredentials({
|
|
29
|
+
api_url: "https://staging.localpulse.nl/",
|
|
30
|
+
token: "lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(await loadStoredCredentials()).toEqual({
|
|
34
|
+
api_url: "https://staging.localpulse.nl",
|
|
35
|
+
token: "lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("writes credentials with owner-only permissions", async () => {
|
|
40
|
+
await saveCredentials({
|
|
41
|
+
api_url: "https://localpulse.nl",
|
|
42
|
+
token: "lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const fileStat = await stat(getCredentialsPath());
|
|
46
|
+
|
|
47
|
+
expect(fileStat.mode & 0o777).toBe(0o600);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("prefers env api urls over stored credentials", async () => {
|
|
51
|
+
await saveCredentials({
|
|
52
|
+
api_url: "https://staging.localpulse.nl",
|
|
53
|
+
token: "lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
54
|
+
});
|
|
55
|
+
process.env.LP_API_URL = "https://localpulse.nl/";
|
|
56
|
+
|
|
57
|
+
expect(await resolveApiUrl()).toBe("https://localpulse.nl");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("deletes stored credentials", async () => {
|
|
61
|
+
await saveCredentials({
|
|
62
|
+
api_url: "https://localpulse.nl",
|
|
63
|
+
token: "lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(await deleteCredentials()).toBe(true);
|
|
67
|
+
expect(await loadStoredCredentials()).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns false when deleting non-existent credentials", async () => {
|
|
71
|
+
expect(await deleteCredentials()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("warns and returns null for corrupted JSON in credentials file", async () => {
|
|
75
|
+
const path = getCredentialsPath();
|
|
76
|
+
await mkdir(dirname(path), { recursive: true });
|
|
77
|
+
await writeFile(path, "not-valid-json{{{", "utf8");
|
|
78
|
+
|
|
79
|
+
const chunks: string[] = [];
|
|
80
|
+
const original = process.stderr.write.bind(process.stderr);
|
|
81
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
82
|
+
chunks.push(String(chunk));
|
|
83
|
+
return true;
|
|
84
|
+
}) as typeof process.stderr.write;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const result = await loadStoredCredentials();
|
|
88
|
+
expect(result).toBeNull();
|
|
89
|
+
expect(chunks.join("")).toContain("corrupted");
|
|
90
|
+
} finally {
|
|
91
|
+
process.stderr.write = original;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("warns and returns null for missing api_url in credentials file", async () => {
|
|
96
|
+
const path = getCredentialsPath();
|
|
97
|
+
await mkdir(dirname(path), { recursive: true });
|
|
98
|
+
await writeFile(path, JSON.stringify({ token: "some-token" }), "utf8");
|
|
99
|
+
|
|
100
|
+
const chunks: string[] = [];
|
|
101
|
+
const original = process.stderr.write.bind(process.stderr);
|
|
102
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
103
|
+
chunks.push(String(chunk));
|
|
104
|
+
return true;
|
|
105
|
+
}) as typeof process.stderr.write;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const result = await loadStoredCredentials();
|
|
109
|
+
expect(result).toBeNull();
|
|
110
|
+
expect(chunks.join("")).toContain("missing api_url");
|
|
111
|
+
} finally {
|
|
112
|
+
process.stderr.write = original;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_API_URL, normalizeApiUrl } from "./api-url";
|
|
6
|
+
|
|
7
|
+
export type StoredCredentials = {
|
|
8
|
+
token?: string;
|
|
9
|
+
api_url: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function getDefaultApiUrl(): string {
|
|
13
|
+
return normalizeApiUrl(process.env.LP_API_URL?.trim());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getCredentialsPath(): string {
|
|
17
|
+
return join(process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "localpulse", "credentials.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function loadStoredCredentials(): Promise<StoredCredentials | null> {
|
|
21
|
+
const path = getCredentialsPath();
|
|
22
|
+
let raw: string;
|
|
23
|
+
try {
|
|
24
|
+
raw = await readFile(path, "utf8");
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (isMissingFileError(error)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let parsed: unknown;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
process.stderr.write(
|
|
37
|
+
`Warning: credentials file is corrupted (${path}). Run \`localpulse auth login\` to re-authenticate.\n`,
|
|
38
|
+
);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const creds = parsed as Partial<StoredCredentials>;
|
|
43
|
+
if (!creds.api_url || typeof creds.api_url !== "string") {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
`Warning: credentials file is missing api_url (${path}). Run \`localpulse auth login\` to re-authenticate.\n`,
|
|
46
|
+
);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
api_url: normalizeApiUrl(creds.api_url),
|
|
52
|
+
token: typeof creds.token === "string" ? creds.token.trim() || undefined : undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function saveCredentials(credentials: StoredCredentials): Promise<string> {
|
|
57
|
+
const path = getCredentialsPath();
|
|
58
|
+
await mkdir(dirname(path), { recursive: true });
|
|
59
|
+
await writeFile(
|
|
60
|
+
path,
|
|
61
|
+
JSON.stringify(
|
|
62
|
+
{
|
|
63
|
+
api_url: normalizeApiUrl(credentials.api_url),
|
|
64
|
+
token: credentials.token?.trim() || undefined,
|
|
65
|
+
},
|
|
66
|
+
null,
|
|
67
|
+
2,
|
|
68
|
+
) + "\n",
|
|
69
|
+
{
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
mode: 0o600,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
await chmod(path, 0o600);
|
|
75
|
+
return path;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function deleteCredentials(): Promise<boolean> {
|
|
79
|
+
const path = getCredentialsPath();
|
|
80
|
+
try {
|
|
81
|
+
await unlink(path);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (isMissingFileError(error)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function resolveApiUrl(): Promise<string> {
|
|
92
|
+
const envApiUrl = process.env.LP_API_URL?.trim();
|
|
93
|
+
if (envApiUrl) {
|
|
94
|
+
return normalizeApiUrl(envApiUrl);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const stored = await loadStoredCredentials();
|
|
98
|
+
return stored?.api_url ?? DEFAULT_API_URL;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function resolveToken(): Promise<string | undefined> {
|
|
102
|
+
const envToken = process.env.LP_TOKEN?.trim();
|
|
103
|
+
if (envToken) {
|
|
104
|
+
return envToken;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stored = await loadStoredCredentials();
|
|
108
|
+
return stored?.token?.trim() || undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isMissingFileError(error: unknown): error is NodeJS.ErrnoException {
|
|
112
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
113
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdtemp } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import { getCredentialsPath, loadStoredCredentials } from "./credentials";
|
|
8
|
+
import { loginWithToken } from "./login";
|
|
9
|
+
|
|
10
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
11
|
+
const ORIGINAL_XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
process.env.XDG_CONFIG_HOME = await mkdtemp(join(tmpdir(), "lp-cli-login-"));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
19
|
+
process.env.XDG_CONFIG_HOME = ORIGINAL_XDG_CONFIG_HOME;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("login", () => {
|
|
23
|
+
it("saves credentials even when CLI info fetch fails", async () => {
|
|
24
|
+
let requestCount = 0;
|
|
25
|
+
globalThis.fetch = mock(async (input, init) => {
|
|
26
|
+
requestCount += 1;
|
|
27
|
+
const url = String(input);
|
|
28
|
+
|
|
29
|
+
if (url === "https://localpulse.nl/api/v1/token/verify") {
|
|
30
|
+
expect(init?.method).toBe("GET");
|
|
31
|
+
expect(init?.headers).toEqual({
|
|
32
|
+
authorization: "Bearer lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
33
|
+
accept: "application/json",
|
|
34
|
+
});
|
|
35
|
+
return new Response(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
authenticated: true,
|
|
38
|
+
email: "test@example.com",
|
|
39
|
+
}),
|
|
40
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (url === "https://localpulse.nl/api/cli/info") {
|
|
45
|
+
expect(init?.method).toBe("GET");
|
|
46
|
+
return new Response("temporarily unavailable", {
|
|
47
|
+
status: 503,
|
|
48
|
+
headers: { "Content-Type": "text/plain" },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
53
|
+
}) as typeof fetch;
|
|
54
|
+
|
|
55
|
+
const result = await loginWithToken(
|
|
56
|
+
"https://localpulse.nl",
|
|
57
|
+
"lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(requestCount).toBe(2);
|
|
61
|
+
expect(result).toMatchObject({
|
|
62
|
+
saved: true,
|
|
63
|
+
api_url: "https://localpulse.nl",
|
|
64
|
+
validation: "authenticated_cli_probe",
|
|
65
|
+
cli_api_version: null,
|
|
66
|
+
credentials_path: getCredentialsPath(),
|
|
67
|
+
});
|
|
68
|
+
expect(await loadStoredCredentials()).toEqual({
|
|
69
|
+
api_url: "https://localpulse.nl",
|
|
70
|
+
token: "lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/lib/login.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { saveCredentials } from "./credentials";
|
|
2
|
+
import { fetchCliInfo } from "./cli-read-client";
|
|
3
|
+
import { verifyCliToken } from "./upload-client";
|
|
4
|
+
|
|
5
|
+
export type LoginResult = {
|
|
6
|
+
saved: true;
|
|
7
|
+
validation: "authenticated_cli_probe";
|
|
8
|
+
api_url: string;
|
|
9
|
+
credentials_path: string;
|
|
10
|
+
cli_api_version: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function loginWithToken(
|
|
14
|
+
apiUrl: string,
|
|
15
|
+
token: string,
|
|
16
|
+
): Promise<LoginResult> {
|
|
17
|
+
await verifyCliToken(apiUrl, token);
|
|
18
|
+
const credentialsPath = await saveCredentials({
|
|
19
|
+
api_url: apiUrl,
|
|
20
|
+
token,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
saved: true,
|
|
25
|
+
validation: "authenticated_cli_probe",
|
|
26
|
+
api_url: apiUrl,
|
|
27
|
+
credentials_path: credentialsPath,
|
|
28
|
+
cli_api_version: await fetchCliInfoVersion(apiUrl),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function fetchCliInfoVersion(apiUrl: string): Promise<string | null> {
|
|
33
|
+
try {
|
|
34
|
+
const info = await fetchCliInfo(apiUrl);
|
|
35
|
+
return info.version ?? null;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof Error) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CliApiError } from "./api-response";
|
|
2
|
+
|
|
3
|
+
export function printError(error: unknown): void {
|
|
4
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function exitCodeForError(error: unknown): number {
|
|
9
|
+
if (error instanceof CliApiError) {
|
|
10
|
+
if (error.httpStatus === 401 || error.httpStatus === 403) return 2;
|
|
11
|
+
if (error.httpStatus >= 500) return 3;
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
if (error instanceof Error && error.message.includes("token")) return 2;
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { type ResearchPayload, validateResearchPayload } from "./research-schema";
|
|
4
|
+
|
|
5
|
+
export async function readResearchPayload(pathOrDash: string): Promise<ResearchPayload> {
|
|
6
|
+
const raw = pathOrDash === "-" ? await readStdin() : await readFile(pathOrDash, "utf8");
|
|
7
|
+
|
|
8
|
+
let parsed: unknown;
|
|
9
|
+
try {
|
|
10
|
+
parsed = JSON.parse(raw);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error("Research payload is not valid JSON.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return validateResearchPayload(parsed);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readStdin(): Promise<string> {
|
|
19
|
+
const chunks: string[] = [];
|
|
20
|
+
const decoder = new TextDecoder();
|
|
21
|
+
for await (const chunk of process.stdin) {
|
|
22
|
+
chunks.push(decoder.decode(chunk as Uint8Array, { stream: true }));
|
|
23
|
+
}
|
|
24
|
+
const result = chunks.join("") + decoder.decode();
|
|
25
|
+
if (!result.trim()) {
|
|
26
|
+
throw new Error("No input received on stdin. Pipe a JSON research payload.");
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|