@glassly/cli 2.0.0-beta.4
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 +64 -0
- package/bin/glassly.js +20 -0
- package/package.json +48 -0
- package/src/api.ts +469 -0
- package/src/config.ts +34 -0
- package/src/credentials.ts +216 -0
- package/src/index.ts +1200 -0
- package/src/open-browser.ts +41 -0
- package/src/signing.ts +109 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
|
|
4
|
+
export async function openBrowser(url: string): Promise<boolean> {
|
|
5
|
+
const command =
|
|
6
|
+
platform() === "darwin"
|
|
7
|
+
? "open"
|
|
8
|
+
: platform() === "win32"
|
|
9
|
+
? "cmd"
|
|
10
|
+
: "xdg-open";
|
|
11
|
+
const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
|
|
12
|
+
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let settled = false;
|
|
15
|
+
const done = (ok: boolean) => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
resolve(ok);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const child = spawn(command, args, {
|
|
22
|
+
detached: true,
|
|
23
|
+
stdio: "ignore",
|
|
24
|
+
});
|
|
25
|
+
// Detach from the child so a long-lived launcher (e.g. xdg-open staying
|
|
26
|
+
// attached to the browser) can't keep the CLI's event loop alive after
|
|
27
|
+
// login completes and we've already resolved.
|
|
28
|
+
child.unref();
|
|
29
|
+
|
|
30
|
+
// A spawn error (e.g. launcher not found) is a genuine failure; callers fall
|
|
31
|
+
// back to printing the URL.
|
|
32
|
+
child.on("error", () => done(false));
|
|
33
|
+
// Some launchers (`open`, `start`) exit promptly; treat a fast non-zero exit
|
|
34
|
+
// as a failure. But others (`xdg-open`) can stay attached to the browser, so
|
|
35
|
+
// don't block the login flow waiting for exit: assume success once a short
|
|
36
|
+
// window passes without an error/early failure so polling can start.
|
|
37
|
+
child.on("close", (code) => done(code === 0));
|
|
38
|
+
const timer = setTimeout(() => done(true), 500);
|
|
39
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
40
|
+
});
|
|
41
|
+
}
|
package/src/signing.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { listSigningKeys, registerSigningKey, type SignedBundleMetadata } from "./api";
|
|
5
|
+
import { loadSigningKey, saveSigningKey, type CliCredentials, type CliJwk, type CliSigningKey } from "./credentials";
|
|
6
|
+
|
|
7
|
+
export interface DevMiniappAttestation {
|
|
8
|
+
packageName: string;
|
|
9
|
+
devServerUrl: string;
|
|
10
|
+
nonce: string;
|
|
11
|
+
expiresAt: string;
|
|
12
|
+
signingKeyId: string;
|
|
13
|
+
signature: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function ensureSigningKey(credentials: CliCredentials): Promise<CliSigningKey> {
|
|
17
|
+
const stored = await loadSigningKey(credentials.coreUrl);
|
|
18
|
+
if (stored && await remoteKeyIsActive(credentials, stored.signingKeyId)) return stored;
|
|
19
|
+
|
|
20
|
+
const pair = crypto.generateKeyPairSync("ed25519");
|
|
21
|
+
const publicKeyJwk = pair.publicKey.export({ format: "jwk" }) as CliJwk;
|
|
22
|
+
const privateKeyJwk = pair.privateKey.export({ format: "jwk" }) as CliJwk;
|
|
23
|
+
const { key } = await registerSigningKey(credentials, { publicKeyJwk });
|
|
24
|
+
const localKey: CliSigningKey = {
|
|
25
|
+
coreUrl: credentials.coreUrl,
|
|
26
|
+
signingKeyId: key.id,
|
|
27
|
+
publicKeyJwk,
|
|
28
|
+
privateKeyJwk,
|
|
29
|
+
createdAt: new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
await saveSigningKey(localKey);
|
|
32
|
+
return localKey;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function signBundleMetadata(input: {
|
|
36
|
+
signingKey: CliSigningKey;
|
|
37
|
+
packageName: string;
|
|
38
|
+
version: string;
|
|
39
|
+
manifest: Record<string, unknown>;
|
|
40
|
+
bundle: Uint8Array;
|
|
41
|
+
}): SignedBundleMetadata {
|
|
42
|
+
const payload = {
|
|
43
|
+
packageName: input.packageName,
|
|
44
|
+
version: input.version,
|
|
45
|
+
bundleSha256: sha256Hex(input.bundle),
|
|
46
|
+
manifestSha256: sha256Hex(Buffer.from(canonicalJson(input.manifest))),
|
|
47
|
+
createdAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
signingKeyId: input.signingKey.signingKeyId,
|
|
51
|
+
payload,
|
|
52
|
+
signature: signPayload(input.signingKey.privateKeyJwk, payload),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function signDevAttestation(input: {
|
|
57
|
+
signingKey: CliSigningKey;
|
|
58
|
+
packageName: string;
|
|
59
|
+
devServerUrl: string;
|
|
60
|
+
ttlMs?: number;
|
|
61
|
+
}): DevMiniappAttestation {
|
|
62
|
+
const payload = {
|
|
63
|
+
packageName: input.packageName,
|
|
64
|
+
devServerUrl: input.devServerUrl,
|
|
65
|
+
nonce: crypto.randomUUID(),
|
|
66
|
+
expiresAt: new Date(Date.now() + (input.ttlMs ?? 15 * 60_000)).toISOString(),
|
|
67
|
+
signingKeyId: input.signingKey.signingKeyId,
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
...payload,
|
|
71
|
+
signature: signPayload(input.signingKey.privateKeyJwk, payload),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function encodeDevAttestation(attestation: DevMiniappAttestation): string {
|
|
76
|
+
return Buffer.from(JSON.stringify(attestation)).toString("base64url");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sha256Hex(bytes: Uint8Array): string {
|
|
80
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function fileSha256Hex(path: string): string {
|
|
84
|
+
return sha256Hex(readFileSync(path));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function canonicalJson(value: unknown): string {
|
|
88
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
89
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
90
|
+
const record = value as Record<string, unknown>;
|
|
91
|
+
return `{${Object.keys(record)
|
|
92
|
+
.sort()
|
|
93
|
+
.map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
94
|
+
.join(",")}}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function signPayload(privateKeyJwk: CliJwk, payload: unknown): string {
|
|
98
|
+
const privateKey = crypto.createPrivateKey({ key: privateKeyJwk, format: "jwk" } as unknown as crypto.PrivateKeyInput);
|
|
99
|
+
return crypto.sign(null, Buffer.from(canonicalJson(payload)), privateKey).toString("base64url");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function remoteKeyIsActive(credentials: CliCredentials, signingKeyId: string): Promise<boolean> {
|
|
103
|
+
try {
|
|
104
|
+
const { keys } = await listSigningKeys(credentials);
|
|
105
|
+
return keys.some(key => key.id === signingKeyId && key.status === "active");
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|