Skip to content
Merged
13 changes: 12 additions & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { spawn } from "node:child_process";
import { readFile, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { homedir } from "node:os";
import { pathToFileURL } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
Comment thread
cursor[bot] marked this conversation as resolved.
import * as z from "zod/mini";

import { refreshToken as baseRefreshToken } from "./clients/auth";
Expand Down Expand Up @@ -198,3 +199,13 @@ async function buildLoginUrl(host: string, port: number): Promise<URL> {
url.searchParams.set("port", port.toString());
return url;
}

export function spawnTokenRefresh(): void {
try {
const script = fileURLToPath(new URL("./subprocesses/refreshToken.mjs", import.meta.url));
const child = spawn(process.execPath, [script], { detached: true, stdio: "ignore" });
child.unref();
} catch {
// Silent failure — never breaks the CLI.
}
}
33 changes: 20 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { parseArgs } from "node:util";

import packageJson from "../package.json" with { type: "json" };
import { getAdapter, NoSupportedFrameworkError } from "./adapters";
import { cleanupLegacyAuthFile, getHost, refreshToken } from "./auth";
import { cleanupLegacyAuthFile, getHost, getToken, spawnTokenRefresh } from "./auth";
import { getProfile } from "./clients/user";
import docs from "./commands/docs";
import field from "./commands/field";
Expand Down Expand Up @@ -42,13 +42,13 @@ import {
sentrySetUser,
setupSentry,
} from "./lib/sentry";
import { decodePayload } from "./lib/jwt";
import { dedent } from "./lib/string";
import { initUpdateNotifier } from "./lib/update-notifier";
import { InvalidPrismicConfigError, MissingPrismicConfigError } from "./project";
import { safeGetRepositoryName, TypeBuilderRequiredError } from "./project";

const UNTRACKED_COMMANDS = ["login", "logout", "whoami", "sync", "docs"];
const SKIP_REFRESH_COMMANDS = ["login", "logout"];

const router = createCommandRouter({
name: "prismic",
Expand Down Expand Up @@ -164,17 +164,24 @@ async function main(): Promise<void> {
// noop - it's okay if we can't set the framework
}

if (command && !SKIP_REFRESH_COMMANDS.includes(command)) {
// Refresh the token and identify the user in the background.
refreshToken()
.then(async (token) => {
if (!token) return;
const host = await getHost();
const profile = await getProfile({ token, host });
segmentIdentify({ shortId: profile.shortId, intercomHash: profile.intercomHash });
sentrySetUser({ id: profile.shortId });
})
.catch(() => {});
const token = await getToken();
if (token) {
const host = await getHost();
const exp = decodePayload(token)?.exp;
const now = Date.now() / 1000;

if (!exp || exp - now <= 3600) {
process.on("exit", () => spawnTokenRefresh());
}

if (!exp || exp > now) {
getProfile({ token, host })
.then((profile) => {
segmentIdentify({ shortId: profile.shortId, intercomHash: profile.intercomHash });
sentrySetUser({ id: profile.shortId });
})
.catch(() => {});
}
}

if (command && !UNTRACKED_COMMANDS.includes(command)) {
Expand Down
17 changes: 17 additions & 0 deletions src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as z from "zod/mini";

const JWTPayloadSchema = z.looseObject({
exp: z.optional(z.number()),
});
export type JWTPayload = z.infer<typeof JWTPayloadSchema>;

export function decodePayload(token: string): JWTPayload | undefined {
try {
const [, encoded] = token.split(".");
if (!encoded) return undefined;
const json = JSON.parse(Buffer.from(encoded, "base64url").toString());
return z.parse(JWTPayloadSchema, json);
} catch {
return undefined;
}
}
6 changes: 4 additions & 2 deletions src/lib/packageJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { x } from "tinyexec";
import { z } from "zod/mini";

import { exists, findUpward, readJsonFile } from "./file";
import { request } from "./request";

const PackageJsonSchema = z.object({
dependencies: z.optional(z.record(z.string(), z.string())),
Expand Down Expand Up @@ -48,8 +49,9 @@ export async function addDependencies(dependencies: Record<string, string>): Pro

export async function getNpmPackageVersion(name: string, tag = "latest"): Promise<string> {
const url = new URL(`${name}/${tag}`, "https://registry.npmjs.org/");
const res = await fetch(url);
const { version } = await res.json();
const { version } = await request(url, {
schema: z.object({ version: z.string() }),
});
return version;
}

Expand Down
42 changes: 28 additions & 14 deletions src/lib/segment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

import packageJson from "../../package.json" with { type: "json" };
import { env } from "../env";
Expand All @@ -11,12 +12,12 @@ const SEGMENT_WRITE_KEY =
process.env.PRISMIC_ENV && process.env.PRISMIC_ENV !== "production"
? "Ng5oKJHCGpSWplZ9ymB7Pu7rm0sTDeiG"
: "cGjidifKefYb6EPaGaqpt8rQXkv5TD6P";

const SEGMENT_TRACK_URL = "https://api.segment.io/v1/track";
const SEGMENT_IDENTIFY_URL = "https://api.segment.io/v1/identify";

let enabled = false;
let anonymousId = "";
let authorization = "";
let userId: string | undefined;
let globalRepository: string | undefined;
const appContext = { app: { name: packageJson.name, version: packageJson.version } };
Expand All @@ -38,7 +39,6 @@ export async function initSegment(): Promise<void> {
}

anonymousId = randomUUID();
authorization = `Basic ${btoa(SEGMENT_WRITE_KEY + ":")}`;
process.on("exit", flushTelemetry);
} catch {
enabled = false;
Expand Down Expand Up @@ -131,9 +131,6 @@ function flushTelemetry(): void {
try {
const payload = Buffer.from(
JSON.stringify({
trackUrl: SEGMENT_TRACK_URL,
identifyUrl: SEGMENT_IDENTIFY_URL,
authorization,
trackEvents: trackQueue.map((e) => ({
...(userId ? { userId } : {}),
anonymousId,
Expand All @@ -146,7 +143,10 @@ function flushTelemetry(): void {
}),
).toString("base64");

const child = spawn(process.execPath, ["--input-type=module", "-e", FLUSH_SCRIPT, payload], {
const script = fileURLToPath(
new URL("./subprocesses/sendSegmentEvents.mjs", import.meta.url),
);
const child = spawn(process.execPath, [script, payload], {
Comment thread
angeloashmore marked this conversation as resolved.
detached: true,
stdio: "ignore",
});
Expand All @@ -159,14 +159,28 @@ function flushTelemetry(): void {
identifyQueue.length = 0;
}

const FLUSH_SCRIPT = `
const {trackUrl, identifyUrl, authorization, trackEvents, identifyEvents} = JSON.parse(Buffer.from(process.argv[1], "base64"));
const h = {"Content-Type": "application/json", Authorization: authorization};
await Promise.allSettled([
...trackEvents.map(e => fetch(trackUrl, {method: "POST", headers: h, body: JSON.stringify(e)}).catch(() => {})),
...identifyEvents.map(e => fetch(identifyUrl, {method: "POST", headers: h, body: JSON.stringify(e)}).catch(() => {})),
]);
`;
export async function sendSegmentEvents(
trackEvents: unknown[],
identifyEvents: unknown[],
): Promise<void> {
const headers = {
"Content-Type": "application/json",
Authorization: `Basic ${btoa(SEGMENT_WRITE_KEY + ":")}`,
};

await Promise.allSettled([
...trackEvents.map((e) =>
fetch(SEGMENT_TRACK_URL, { method: "POST", headers, body: JSON.stringify(e) }).catch(
() => {},
),
),
...identifyEvents.map((e) =>
fetch(SEGMENT_IDENTIFY_URL, { method: "POST", headers, body: JSON.stringify(e) }).catch(
() => {},
),
),
]);
}

async function isTelemetryEnabled(): Promise<boolean> {
try {
Expand Down
57 changes: 23 additions & 34 deletions src/lib/update-notifier.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import * as z from "zod/mini";

import packageJson from "../../package.json" with { type: "json" };
import { stringify } from "./json";
import { getNpmPackageVersion } from "./packageJson";

const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 2000;

const UpdateNotifierStateSchema = z.looseObject({
latestKnownVersion: z.optional(z.string()),
Expand Down Expand Up @@ -81,43 +83,30 @@ function isNewer(latest: string, current: string): boolean {
return false;
}

/**
* Spawns a detached subprocess to fetch the latest version from the npm
* registry and persist it to the state file. The main process exits
* immediately; the subprocess handles HTTP delivery and the file write.
*/
export async function updateVersionState(
npmPackageName: string,
statePath: URL,
): Promise<void> {
const version = await getNpmPackageVersion(npmPackageName);
const filePath = fileURLToPath(statePath);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(
filePath,
stringify({ latestKnownVersion: version, lastUpdateCheckAt: Date.now() }),
);
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
angeloashmore marked this conversation as resolved.

function spawnBackgroundCheck(npmPackageName: string, statePath: URL): void {
try {
const payload = Buffer.from(
JSON.stringify({
npmPackageName,
statePath: fileURLToPath(statePath),
timeoutMs: FETCH_TIMEOUT_MS,
}),
).toString("base64");

const child = spawn(
process.execPath,
["--input-type=module", "-e", BACKGROUND_CHECK_SCRIPT, payload],
{ detached: true, stdio: "ignore" },
const script = fileURLToPath(
new URL("./subprocesses/updateVersionState.mjs", import.meta.url),
);
const child = spawn(process.execPath, [script, npmPackageName, statePath.href], {
detached: true,
stdio: "ignore",
});
child.unref();
} catch {
// Silent failure — never breaks the CLI.
}
}

const BACKGROUND_CHECK_SCRIPT = `
const {npmPackageName, statePath, timeoutMs} = JSON.parse(Buffer.from(process.argv[1], "base64").toString());
try {
const url = \`https://registry.npmjs.org/\${npmPackageName}/latest\`;
const res = await fetch(url, {signal: AbortSignal.timeout(timeoutMs)});
if (!res.ok) process.exit(0);
const {version} = await res.json();
if (typeof version !== "string") process.exit(0);
const fs = await import("node:fs/promises");
const {dirname} = await import("node:path");
await fs.mkdir(dirname(statePath), {recursive: true});
await fs.writeFile(statePath, JSON.stringify({latestKnownVersion: version, lastUpdateCheckAt: Date.now()}, null, 2));
} catch {}
`;
5 changes: 5 additions & 0 deletions src/subprocesses/refreshToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { refreshToken } from "../auth";

try {
await refreshToken();
} catch {}
9 changes: 9 additions & 0 deletions src/subprocesses/sendSegmentEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { sendSegmentEvents } from "../lib/segment";

const { trackEvents, identifyEvents } = JSON.parse(
Buffer.from(process.argv[2], "base64").toString(),
);

try {
await sendSegmentEvents(trackEvents, identifyEvents);
} catch {}
7 changes: 7 additions & 0 deletions src/subprocesses/updateVersionState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { updateVersionState } from "../lib/update-notifier";

const [npmPackageName, statePathHref] = process.argv.slice(2);

try {
await updateVersionState(npmPackageName, new URL(statePathHref));
} catch {}
7 changes: 6 additions & 1 deletion tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ const MODE = process.env.MODE || "development";
const TEST = MODE === "test";

export default defineConfig({
entry: "./src/index.ts",
entry: {
index: "./src/index.ts",
"subprocesses/refreshToken": "./src/subprocesses/refreshToken.ts",
"subprocesses/sendSegmentEvents": "./src/subprocesses/sendSegmentEvents.ts",
"subprocesses/updateVersionState": "./src/subprocesses/updateVersionState.ts",
},
format: "esm",
platform: "node",
unbundle: TEST,
Expand Down