@hienlh/ppm 0.7.28 → 0.7.30
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/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/cli/commands/autostart.ts +94 -0
- package/src/cli/commands/restart.ts +14 -5
- package/src/index.ts +3 -0
- package/src/services/autostart-generator.ts +213 -0
- package/src/services/autostart-register.ts +348 -0
- package/test-claude-oauth-v2.mjs +0 -165
- package/test-claude-oauth.mjs +0 -175
- package/test-verify-oat.mjs +0 -106
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.30] - 2026-03-23
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Restart from PPM terminal**: worker and restart command now ignore SIGHUP — killing the old server destroys the terminal PTY which sends SIGHUP to the process group, previously killing the worker before it could spawn the new server
|
|
7
|
+
|
|
8
|
+
## [0.7.29] - 2026-03-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Auto-start on boot**: `ppm autostart enable/disable/status` registers PPM to start automatically via OS-native mechanisms — macOS launchd, Linux systemd, Windows Registry Run key
|
|
12
|
+
- **Cross-platform support**: auto-detects compiled binary vs bun runtime, resolves paths correctly on all platforms
|
|
13
|
+
- **VBScript hidden wrapper**: Windows auto-start runs PPM without visible console window
|
|
14
|
+
- **GitHub Actions CI**: test matrix for macOS, Linux, and Windows with unit + integration tests
|
|
15
|
+
|
|
16
|
+
### Technical
|
|
17
|
+
- 2-layer architecture: generator (pure functions, testable) + register (OS interaction)
|
|
18
|
+
- 40 unit tests + 20 integration tests (platform-specific with `describe.if`)
|
|
19
|
+
- KeepAlive/Restart-on-failure for crash recovery on all platforms
|
|
20
|
+
|
|
3
21
|
## [0.7.28] - 2026-03-22
|
|
4
22
|
|
|
5
23
|
### Added
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { configService } from "../../services/config.service.ts";
|
|
3
|
+
|
|
4
|
+
export function registerAutoStartCommands(program: Command): void {
|
|
5
|
+
const cmd = program
|
|
6
|
+
.command("autostart")
|
|
7
|
+
.description("Manage auto-start on boot (enable/disable/status)");
|
|
8
|
+
|
|
9
|
+
cmd
|
|
10
|
+
.command("enable")
|
|
11
|
+
.description("Register PPM to start automatically on boot")
|
|
12
|
+
.option("-p, --port <port>", "Override port")
|
|
13
|
+
.option("-s, --share", "Enable Cloudflare tunnel on boot")
|
|
14
|
+
.option("-c, --config <path>", "Config file path")
|
|
15
|
+
.option("--profile <name>", "DB profile name")
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
const { enableAutoStart } = await import("../../services/autostart-register.ts");
|
|
18
|
+
|
|
19
|
+
configService.load(options.config);
|
|
20
|
+
const port = parseInt(options.port ?? String(configService.get("port")), 10);
|
|
21
|
+
const host = configService.get("host") ?? "0.0.0.0";
|
|
22
|
+
|
|
23
|
+
const config = {
|
|
24
|
+
port,
|
|
25
|
+
host,
|
|
26
|
+
share: !!options.share,
|
|
27
|
+
configPath: options.config,
|
|
28
|
+
profile: options.profile,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
console.log(" Registering auto-start...\n");
|
|
33
|
+
const servicePath = await enableAutoStart(config);
|
|
34
|
+
console.log(` ✓ Auto-start enabled`);
|
|
35
|
+
console.log(` Service: ${servicePath}`);
|
|
36
|
+
console.log(` Port: ${port}`);
|
|
37
|
+
if (options.share) console.log(` Tunnel: enabled`);
|
|
38
|
+
console.log(`\n PPM will start automatically on boot.`);
|
|
39
|
+
|
|
40
|
+
if (process.platform === "darwin") {
|
|
41
|
+
console.log(`\n Note: On macOS Ventura+, you may need to allow PPM in`);
|
|
42
|
+
console.log(` System Settings > General > Login Items.`);
|
|
43
|
+
}
|
|
44
|
+
if (process.platform === "linux") {
|
|
45
|
+
console.log(`\n Note: 'loginctl enable-linger' was called to allow`);
|
|
46
|
+
console.log(` boot-time start without login.`);
|
|
47
|
+
}
|
|
48
|
+
console.log();
|
|
49
|
+
} catch (err: unknown) {
|
|
50
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
51
|
+
console.error(` ✗ Failed to enable auto-start: ${msg}\n`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
cmd
|
|
57
|
+
.command("disable")
|
|
58
|
+
.description("Remove PPM auto-start registration")
|
|
59
|
+
.action(async () => {
|
|
60
|
+
const { disableAutoStart } = await import("../../services/autostart-register.ts");
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await disableAutoStart();
|
|
64
|
+
console.log(" ✓ Auto-start disabled. PPM will no longer start on boot.\n");
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
67
|
+
console.error(` ✗ Failed to disable auto-start: ${msg}\n`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
cmd
|
|
73
|
+
.command("status")
|
|
74
|
+
.description("Show auto-start status")
|
|
75
|
+
.option("--json", "Output as JSON")
|
|
76
|
+
.action(async (options) => {
|
|
77
|
+
const { getAutoStartStatus } = await import("../../services/autostart-register.ts");
|
|
78
|
+
|
|
79
|
+
const status = getAutoStartStatus();
|
|
80
|
+
|
|
81
|
+
if (options.json) {
|
|
82
|
+
console.log(JSON.stringify(status));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(`\n Auto-start status\n`);
|
|
87
|
+
console.log(` Platform: ${status.platform}`);
|
|
88
|
+
console.log(` Enabled: ${status.enabled ? "yes" : "no"}`);
|
|
89
|
+
console.log(` Running: ${status.running ? "yes" : "no"}`);
|
|
90
|
+
if (status.servicePath) console.log(` Service: ${status.servicePath}`);
|
|
91
|
+
console.log(` Details: ${status.details}`);
|
|
92
|
+
console.log();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -10,6 +10,9 @@ const RESTART_RESULT = resolve(PPM_DIR, ".restart-result");
|
|
|
10
10
|
|
|
11
11
|
/** Restart only the server process, keeping the tunnel alive */
|
|
12
12
|
export async function restartServer(options: { config?: string }) {
|
|
13
|
+
// Ignore SIGHUP so this process survives when PPM terminal dies
|
|
14
|
+
process.on("SIGHUP", () => {});
|
|
15
|
+
|
|
13
16
|
if (!existsSync(STATUS_FILE)) {
|
|
14
17
|
console.log("No PPM daemon running. Use 'ppm start' instead.");
|
|
15
18
|
process.exit(1);
|
|
@@ -51,7 +54,8 @@ export async function restartServer(options: { config?: string }) {
|
|
|
51
54
|
console.log(" If you're using PPM terminal, wait a few seconds then reload the page.\n");
|
|
52
55
|
|
|
53
56
|
// Generate a self-contained restart worker script.
|
|
54
|
-
//
|
|
57
|
+
// Worker ignores SIGHUP so it survives when killing the server causes the
|
|
58
|
+
// terminal (and its process group) to receive SIGHUP.
|
|
55
59
|
const params = JSON.stringify({
|
|
56
60
|
serverPid, port, host, serverScript,
|
|
57
61
|
config: options.config ?? "",
|
|
@@ -67,6 +71,11 @@ export async function restartServer(options: { config?: string }) {
|
|
|
67
71
|
import { readFileSync, writeFileSync, openSync, unlinkSync, appendFileSync } from "node:fs";
|
|
68
72
|
import { createServer } from "node:net";
|
|
69
73
|
|
|
74
|
+
// Ignore SIGHUP — when we kill the old server, the terminal PTY dies and
|
|
75
|
+
// SIGHUP is sent to the entire process group. Without this, the worker
|
|
76
|
+
// would be killed before it can spawn the new server.
|
|
77
|
+
process.on("SIGHUP", () => {});
|
|
78
|
+
|
|
70
79
|
const P = ${params};
|
|
71
80
|
|
|
72
81
|
async function main() {
|
|
@@ -163,7 +172,8 @@ main();
|
|
|
163
172
|
`);
|
|
164
173
|
|
|
165
174
|
// Spawn worker as a fully detached process
|
|
166
|
-
const
|
|
175
|
+
const logFile = resolve(PPM_DIR, "ppm.log");
|
|
176
|
+
const logFd = openSync(logFile, "a");
|
|
167
177
|
const worker = Bun.spawn({
|
|
168
178
|
cmd: [process.execPath, "run", workerPath],
|
|
169
179
|
stdio: ["ignore", logFd, logFd],
|
|
@@ -171,9 +181,8 @@ main();
|
|
|
171
181
|
});
|
|
172
182
|
worker.unref();
|
|
173
183
|
|
|
174
|
-
// Poll for result —
|
|
175
|
-
//
|
|
176
|
-
// the user already saw the pre-restart message above.
|
|
184
|
+
// Poll for result — works from both PPM terminal (SIGHUP-immune) and external terminal.
|
|
185
|
+
// Output may not be visible if PPM terminal PTY is dead, but process stays alive.
|
|
177
186
|
const pollStart = Date.now();
|
|
178
187
|
while (Date.now() - pollStart < 20000) {
|
|
179
188
|
await Bun.sleep(500);
|
package/src/index.ts
CHANGED
|
@@ -121,4 +121,7 @@ registerGitCommands(program);
|
|
|
121
121
|
const { registerChatCommands } = await import("./cli/commands/chat-cmd.ts");
|
|
122
122
|
registerChatCommands(program);
|
|
123
123
|
|
|
124
|
+
const { registerAutoStartCommands } = await import("./cli/commands/autostart.ts");
|
|
125
|
+
registerAutoStartCommands(program);
|
|
126
|
+
|
|
124
127
|
program.parse();
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface AutoStartConfig {
|
|
5
|
+
port: number;
|
|
6
|
+
host: string;
|
|
7
|
+
share: boolean;
|
|
8
|
+
configPath?: string;
|
|
9
|
+
profile?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Detect whether running from compiled binary or bun runtime */
|
|
13
|
+
export function isCompiledBinary(): boolean {
|
|
14
|
+
// Compiled Bun binaries don't have "bun" in execPath
|
|
15
|
+
return !process.execPath.includes("bun");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve the absolute path to the bun binary */
|
|
19
|
+
export function resolveBunPath(): string {
|
|
20
|
+
// 1. Current process is bun itself
|
|
21
|
+
if (process.execPath.includes("bun")) return process.execPath;
|
|
22
|
+
|
|
23
|
+
// 2. Check ~/.bun/bin/bun
|
|
24
|
+
const home = homedir();
|
|
25
|
+
const bunHome = resolve(home, ".bun", "bin", "bun");
|
|
26
|
+
try {
|
|
27
|
+
const { existsSync } = require("node:fs") as typeof import("node:fs");
|
|
28
|
+
if (existsSync(bunHome)) return bunHome;
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
// 3. Check PATH via which/where
|
|
32
|
+
try {
|
|
33
|
+
const cmd = process.platform === "win32" ? ["where", "bun"] : ["which", "bun"];
|
|
34
|
+
const result = Bun.spawnSync({ cmd, stdout: "pipe", stderr: "ignore" });
|
|
35
|
+
const path = result.stdout.toString().trim().split("\n")[0];
|
|
36
|
+
if (path) return path;
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
throw new Error("Could not resolve bun binary. Install Bun or add it to PATH.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build the command array for the PPM server process */
|
|
43
|
+
export function buildExecCommand(config: AutoStartConfig): string[] {
|
|
44
|
+
if (isCompiledBinary()) {
|
|
45
|
+
// Compiled binary: just run self with __serve__ args
|
|
46
|
+
const args = [process.execPath, "__serve__", String(config.port), config.host];
|
|
47
|
+
if (config.configPath) args.push(config.configPath);
|
|
48
|
+
if (config.profile) args.push(config.profile);
|
|
49
|
+
return args;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Bun runtime: bun run <script> __serve__ <port> <host> [config] [profile]
|
|
53
|
+
const bunPath = resolveBunPath();
|
|
54
|
+
const scriptPath = resolve(import.meta.dir, "..", "server", "index.ts");
|
|
55
|
+
const args = [bunPath, "run", scriptPath, "__serve__", String(config.port), config.host];
|
|
56
|
+
if (config.configPath) args.push(config.configPath);
|
|
57
|
+
else args.push(""); // placeholder
|
|
58
|
+
if (config.profile) args.push(config.profile);
|
|
59
|
+
else args.push(""); // placeholder
|
|
60
|
+
return args;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── macOS launchd plist ────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const PLIST_LABEL = "com.hienlh.ppm";
|
|
66
|
+
|
|
67
|
+
export function getPlistPath(): string {
|
|
68
|
+
return resolve(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Generate macOS launchd plist XML content */
|
|
72
|
+
export function generatePlist(config: AutoStartConfig): string {
|
|
73
|
+
const cmd = buildExecCommand(config);
|
|
74
|
+
const logPath = resolve(homedir(), ".ppm", "ppm-launchd.log");
|
|
75
|
+
|
|
76
|
+
const programArgs = cmd
|
|
77
|
+
.map((arg) => ` <string>${escapeXml(arg)}</string>`)
|
|
78
|
+
.join("\n");
|
|
79
|
+
|
|
80
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
81
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
82
|
+
<plist version="1.0">
|
|
83
|
+
<dict>
|
|
84
|
+
<key>Label</key>
|
|
85
|
+
<string>${PLIST_LABEL}</string>
|
|
86
|
+
<key>ProgramArguments</key>
|
|
87
|
+
<array>
|
|
88
|
+
${programArgs}
|
|
89
|
+
</array>
|
|
90
|
+
<key>RunAtLoad</key>
|
|
91
|
+
<true/>
|
|
92
|
+
<key>KeepAlive</key>
|
|
93
|
+
<dict>
|
|
94
|
+
<key>SuccessfulExit</key>
|
|
95
|
+
<false/>
|
|
96
|
+
</dict>
|
|
97
|
+
<key>StandardOutPath</key>
|
|
98
|
+
<string>${escapeXml(logPath)}</string>
|
|
99
|
+
<key>StandardErrorPath</key>
|
|
100
|
+
<string>${escapeXml(logPath)}</string>
|
|
101
|
+
<key>WorkingDirectory</key>
|
|
102
|
+
<string>${escapeXml(resolve(homedir(), ".ppm"))}</string>
|
|
103
|
+
<key>ThrottleInterval</key>
|
|
104
|
+
<integer>10</integer>
|
|
105
|
+
</dict>
|
|
106
|
+
</plist>
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Linux systemd service ──────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function getServicePath(): string {
|
|
113
|
+
return resolve(homedir(), ".config", "systemd", "user", "ppm.service");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Generate Linux systemd user service file content */
|
|
117
|
+
export function generateSystemdService(config: AutoStartConfig): string {
|
|
118
|
+
const cmd = buildExecCommand(config);
|
|
119
|
+
const execStart = cmd.map(shellEscape).join(" ");
|
|
120
|
+
const bunDir = isCompiledBinary() ? "" : resolve(resolveBunPath(), "..");
|
|
121
|
+
|
|
122
|
+
// Build PATH with bun directory prepended
|
|
123
|
+
const envPath = bunDir
|
|
124
|
+
? `Environment="PATH=${bunDir}:/usr/local/bin:/usr/bin:/bin"`
|
|
125
|
+
: "";
|
|
126
|
+
|
|
127
|
+
return `[Unit]
|
|
128
|
+
Description=PPM - Personal Project Manager
|
|
129
|
+
Documentation=https://github.com/hienlh/ppm
|
|
130
|
+
After=network-online.target
|
|
131
|
+
Wants=network-online.target
|
|
132
|
+
|
|
133
|
+
[Service]
|
|
134
|
+
Type=simple
|
|
135
|
+
ExecStart=${execStart}
|
|
136
|
+
Restart=on-failure
|
|
137
|
+
RestartSec=5
|
|
138
|
+
${envPath}
|
|
139
|
+
WorkingDirectory=${homedir()}/.ppm
|
|
140
|
+
|
|
141
|
+
[Install]
|
|
142
|
+
WantedBy=default.target
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Windows Registry Run key ───────────────────────────────────────────
|
|
147
|
+
// Uses HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run — no admin needed
|
|
148
|
+
|
|
149
|
+
const TASK_NAME = "PPM";
|
|
150
|
+
const WIN_REG_KEY = "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
151
|
+
|
|
152
|
+
/** Generate Windows VBScript wrapper content to run PPM hidden */
|
|
153
|
+
export function generateVbsWrapper(config: AutoStartConfig): string {
|
|
154
|
+
const cmd = buildExecCommand(config);
|
|
155
|
+
const exe = cmd[0]!;
|
|
156
|
+
const args = cmd.slice(1).join(" ");
|
|
157
|
+
return `Set objShell = CreateObject("WScript.Shell")
|
|
158
|
+
objShell.Run """${exe.replace(/\\/g, "\\\\")}""` +
|
|
159
|
+
` ${args.replace(/"/g, '""')}", 0, False
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function getVbsPath(): string {
|
|
164
|
+
return resolve(homedir(), ".ppm", "run-ppm.vbs");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Build reg command to add PPM to Windows startup (no admin) */
|
|
168
|
+
export function buildRegAddCommand(vbsPath: string): string[] {
|
|
169
|
+
return [
|
|
170
|
+
"reg", "add", WIN_REG_KEY,
|
|
171
|
+
"/v", TASK_NAME,
|
|
172
|
+
"/t", "REG_SZ",
|
|
173
|
+
"/d", `cscript.exe "${vbsPath}"`,
|
|
174
|
+
"/f",
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Build reg command to remove PPM from Windows startup */
|
|
179
|
+
export function buildRegDeleteCommand(): string[] {
|
|
180
|
+
return [
|
|
181
|
+
"reg", "delete", WIN_REG_KEY,
|
|
182
|
+
"/v", TASK_NAME,
|
|
183
|
+
"/f",
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Build reg command to query PPM startup entry */
|
|
188
|
+
export function buildRegQueryCommand(): string[] {
|
|
189
|
+
return [
|
|
190
|
+
"reg", "query", WIN_REG_KEY,
|
|
191
|
+
"/v", TASK_NAME,
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/** Escape special XML characters */
|
|
198
|
+
function escapeXml(s: string): string {
|
|
199
|
+
return s
|
|
200
|
+
.replace(/&/g, "&")
|
|
201
|
+
.replace(/</g, "<")
|
|
202
|
+
.replace(/>/g, ">")
|
|
203
|
+
.replace(/"/g, """)
|
|
204
|
+
.replace(/'/g, "'");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Escape a string for shell usage (wrap in quotes if contains spaces) */
|
|
208
|
+
function shellEscape(s: string): string {
|
|
209
|
+
if (/["\s]/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
|
|
210
|
+
return s;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export { PLIST_LABEL, TASK_NAME };
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
type AutoStartConfig,
|
|
6
|
+
PLIST_LABEL,
|
|
7
|
+
TASK_NAME,
|
|
8
|
+
generatePlist,
|
|
9
|
+
getPlistPath,
|
|
10
|
+
generateSystemdService,
|
|
11
|
+
getServicePath,
|
|
12
|
+
generateVbsWrapper,
|
|
13
|
+
getVbsPath,
|
|
14
|
+
buildRegAddCommand,
|
|
15
|
+
buildRegDeleteCommand,
|
|
16
|
+
buildRegQueryCommand,
|
|
17
|
+
} from "./autostart-generator.ts";
|
|
18
|
+
|
|
19
|
+
export interface AutoStartStatus {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
running: boolean;
|
|
22
|
+
platform: string;
|
|
23
|
+
servicePath: string | null;
|
|
24
|
+
details: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const METADATA_FILE = resolve(homedir(), ".ppm", "autostart.json");
|
|
28
|
+
|
|
29
|
+
interface AutoStartMetadata {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
platform: string;
|
|
32
|
+
servicePath: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
config: AutoStartConfig;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Save autostart metadata to ~/.ppm/autostart.json */
|
|
38
|
+
function saveMetadata(meta: AutoStartMetadata): void {
|
|
39
|
+
const dir = dirname(METADATA_FILE);
|
|
40
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
41
|
+
writeFileSync(METADATA_FILE, JSON.stringify(meta, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Load autostart metadata */
|
|
45
|
+
function loadMetadata(): AutoStartMetadata | null {
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(METADATA_FILE)) return null;
|
|
48
|
+
return JSON.parse(readFileSync(METADATA_FILE, "utf-8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Remove autostart metadata file */
|
|
55
|
+
function removeMetadata(): void {
|
|
56
|
+
try {
|
|
57
|
+
if (existsSync(METADATA_FILE)) unlinkSync(METADATA_FILE);
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── macOS ──────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
async function enableMacOS(config: AutoStartConfig): Promise<string> {
|
|
64
|
+
const plistPath = getPlistPath();
|
|
65
|
+
const plistDir = dirname(plistPath);
|
|
66
|
+
|
|
67
|
+
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
|
|
68
|
+
writeFileSync(plistPath, generatePlist(config));
|
|
69
|
+
|
|
70
|
+
// Unload first if already loaded (ignore errors)
|
|
71
|
+
Bun.spawnSync({
|
|
72
|
+
cmd: ["launchctl", "bootout", `gui/${process.getuid!()}`, plistPath],
|
|
73
|
+
stdout: "ignore", stderr: "ignore",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Load the agent
|
|
77
|
+
const result = Bun.spawnSync({
|
|
78
|
+
cmd: ["launchctl", "bootstrap", `gui/${process.getuid!()}`, plistPath],
|
|
79
|
+
stdout: "pipe", stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (result.exitCode !== 0) {
|
|
83
|
+
// Fallback to legacy syntax
|
|
84
|
+
const legacy = Bun.spawnSync({
|
|
85
|
+
cmd: ["launchctl", "load", plistPath],
|
|
86
|
+
stdout: "pipe", stderr: "pipe",
|
|
87
|
+
});
|
|
88
|
+
if (legacy.exitCode !== 0) {
|
|
89
|
+
const err = legacy.stderr.toString().trim();
|
|
90
|
+
throw new Error(`launchctl load failed: ${err}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
saveMetadata({
|
|
95
|
+
enabled: true,
|
|
96
|
+
platform: "darwin",
|
|
97
|
+
servicePath: plistPath,
|
|
98
|
+
createdAt: new Date().toISOString(),
|
|
99
|
+
config,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return plistPath;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function disableMacOS(): Promise<void> {
|
|
106
|
+
const plistPath = getPlistPath();
|
|
107
|
+
|
|
108
|
+
// Unload
|
|
109
|
+
Bun.spawnSync({
|
|
110
|
+
cmd: ["launchctl", "bootout", `gui/${process.getuid!()}`, plistPath],
|
|
111
|
+
stdout: "ignore", stderr: "ignore",
|
|
112
|
+
});
|
|
113
|
+
// Legacy fallback
|
|
114
|
+
Bun.spawnSync({
|
|
115
|
+
cmd: ["launchctl", "unload", plistPath],
|
|
116
|
+
stdout: "ignore", stderr: "ignore",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Remove plist file
|
|
120
|
+
try { if (existsSync(plistPath)) unlinkSync(plistPath); } catch {}
|
|
121
|
+
removeMetadata();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function statusMacOS(): AutoStartStatus {
|
|
125
|
+
const plistPath = getPlistPath();
|
|
126
|
+
const fileExists = existsSync(plistPath);
|
|
127
|
+
|
|
128
|
+
// Check if loaded
|
|
129
|
+
const result = Bun.spawnSync({
|
|
130
|
+
cmd: ["launchctl", "list"],
|
|
131
|
+
stdout: "pipe", stderr: "ignore",
|
|
132
|
+
});
|
|
133
|
+
const output = result.stdout.toString();
|
|
134
|
+
const isLoaded = output.includes(PLIST_LABEL);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
enabled: fileExists && isLoaded,
|
|
138
|
+
running: isLoaded,
|
|
139
|
+
platform: "darwin (launchd)",
|
|
140
|
+
servicePath: fileExists ? plistPath : null,
|
|
141
|
+
details: fileExists
|
|
142
|
+
? isLoaded ? "Loaded and enabled" : "Plist exists but not loaded"
|
|
143
|
+
: "Not configured",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Linux ──────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async function enableLinux(config: AutoStartConfig): Promise<string> {
|
|
150
|
+
const servicePath = getServicePath();
|
|
151
|
+
const serviceDir = dirname(servicePath);
|
|
152
|
+
|
|
153
|
+
if (!existsSync(serviceDir)) mkdirSync(serviceDir, { recursive: true });
|
|
154
|
+
writeFileSync(servicePath, generateSystemdService(config));
|
|
155
|
+
|
|
156
|
+
// Reload daemon
|
|
157
|
+
const reload = Bun.spawnSync({
|
|
158
|
+
cmd: ["systemctl", "--user", "daemon-reload"],
|
|
159
|
+
stdout: "ignore", stderr: "pipe",
|
|
160
|
+
});
|
|
161
|
+
if (reload.exitCode !== 0) {
|
|
162
|
+
throw new Error(`systemctl daemon-reload failed: ${reload.stderr.toString().trim()}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Enable
|
|
166
|
+
const enable = Bun.spawnSync({
|
|
167
|
+
cmd: ["systemctl", "--user", "enable", "ppm.service"],
|
|
168
|
+
stdout: "pipe", stderr: "pipe",
|
|
169
|
+
});
|
|
170
|
+
if (enable.exitCode !== 0) {
|
|
171
|
+
throw new Error(`systemctl enable failed: ${enable.stderr.toString().trim()}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Start
|
|
175
|
+
Bun.spawnSync({
|
|
176
|
+
cmd: ["systemctl", "--user", "start", "ppm.service"],
|
|
177
|
+
stdout: "ignore", stderr: "ignore",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Enable lingering so service runs at boot without login
|
|
181
|
+
Bun.spawnSync({
|
|
182
|
+
cmd: ["loginctl", "enable-linger", process.env.USER || ""],
|
|
183
|
+
stdout: "ignore", stderr: "ignore",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
saveMetadata({
|
|
187
|
+
enabled: true,
|
|
188
|
+
platform: "linux",
|
|
189
|
+
servicePath,
|
|
190
|
+
createdAt: new Date().toISOString(),
|
|
191
|
+
config,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return servicePath;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function disableLinux(): Promise<void> {
|
|
198
|
+
// Stop + disable
|
|
199
|
+
Bun.spawnSync({
|
|
200
|
+
cmd: ["systemctl", "--user", "stop", "ppm.service"],
|
|
201
|
+
stdout: "ignore", stderr: "ignore",
|
|
202
|
+
});
|
|
203
|
+
Bun.spawnSync({
|
|
204
|
+
cmd: ["systemctl", "--user", "disable", "ppm.service"],
|
|
205
|
+
stdout: "ignore", stderr: "ignore",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Remove service file
|
|
209
|
+
const servicePath = getServicePath();
|
|
210
|
+
try { if (existsSync(servicePath)) unlinkSync(servicePath); } catch {}
|
|
211
|
+
|
|
212
|
+
// Reload
|
|
213
|
+
Bun.spawnSync({
|
|
214
|
+
cmd: ["systemctl", "--user", "daemon-reload"],
|
|
215
|
+
stdout: "ignore", stderr: "ignore",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
removeMetadata();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function statusLinux(): AutoStartStatus {
|
|
222
|
+
const servicePath = getServicePath();
|
|
223
|
+
const fileExists = existsSync(servicePath);
|
|
224
|
+
|
|
225
|
+
// Check enabled
|
|
226
|
+
const enabled = Bun.spawnSync({
|
|
227
|
+
cmd: ["systemctl", "--user", "is-enabled", "ppm.service"],
|
|
228
|
+
stdout: "pipe", stderr: "ignore",
|
|
229
|
+
});
|
|
230
|
+
const isEnabled = enabled.stdout.toString().trim() === "enabled";
|
|
231
|
+
|
|
232
|
+
// Check active
|
|
233
|
+
const active = Bun.spawnSync({
|
|
234
|
+
cmd: ["systemctl", "--user", "is-active", "ppm.service"],
|
|
235
|
+
stdout: "pipe", stderr: "ignore",
|
|
236
|
+
});
|
|
237
|
+
const isActive = active.stdout.toString().trim() === "active";
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
enabled: isEnabled,
|
|
241
|
+
running: isActive,
|
|
242
|
+
platform: "linux (systemd)",
|
|
243
|
+
servicePath: fileExists ? servicePath : null,
|
|
244
|
+
details: !fileExists
|
|
245
|
+
? "Not configured"
|
|
246
|
+
: isEnabled && isActive ? "Enabled and running"
|
|
247
|
+
: isEnabled ? "Enabled but not running"
|
|
248
|
+
: "Service file exists but not enabled",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Windows ────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
async function enableWindows(config: AutoStartConfig): Promise<string> {
|
|
255
|
+
const vbsPath = getVbsPath();
|
|
256
|
+
const vbsDir = dirname(vbsPath);
|
|
257
|
+
|
|
258
|
+
if (!existsSync(vbsDir)) mkdirSync(vbsDir, { recursive: true });
|
|
259
|
+
writeFileSync(vbsPath, generateVbsWrapper(config));
|
|
260
|
+
|
|
261
|
+
// Add to HKCU Run key (no admin required)
|
|
262
|
+
const cmd = buildRegAddCommand(vbsPath);
|
|
263
|
+
const result = Bun.spawnSync({ cmd, stdout: "pipe", stderr: "pipe" });
|
|
264
|
+
|
|
265
|
+
if (result.exitCode !== 0) {
|
|
266
|
+
const err = result.stderr.toString().trim();
|
|
267
|
+
throw new Error(`Registry add failed: ${err}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
saveMetadata({
|
|
271
|
+
enabled: true,
|
|
272
|
+
platform: "win32",
|
|
273
|
+
servicePath: vbsPath,
|
|
274
|
+
createdAt: new Date().toISOString(),
|
|
275
|
+
config,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return vbsPath;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function disableWindows(): Promise<void> {
|
|
282
|
+
// Remove from registry
|
|
283
|
+
const cmd = buildRegDeleteCommand();
|
|
284
|
+
Bun.spawnSync({ cmd, stdout: "ignore", stderr: "ignore" });
|
|
285
|
+
|
|
286
|
+
// Remove VBS wrapper
|
|
287
|
+
const vbsPath = getVbsPath();
|
|
288
|
+
try { if (existsSync(vbsPath)) unlinkSync(vbsPath); } catch {}
|
|
289
|
+
|
|
290
|
+
removeMetadata();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function statusWindows(): AutoStartStatus {
|
|
294
|
+
const vbsPath = getVbsPath();
|
|
295
|
+
const fileExists = existsSync(vbsPath);
|
|
296
|
+
|
|
297
|
+
// Check if registry entry exists
|
|
298
|
+
const cmd = buildRegQueryCommand();
|
|
299
|
+
const result = Bun.spawnSync({ cmd, stdout: "pipe", stderr: "ignore" });
|
|
300
|
+
const regExists = result.exitCode === 0 && result.stdout.toString().includes(TASK_NAME);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
enabled: regExists,
|
|
304
|
+
running: false, // Can't detect running state from registry
|
|
305
|
+
platform: "windows (Registry Run)",
|
|
306
|
+
servicePath: fileExists ? vbsPath : null,
|
|
307
|
+
details: regExists
|
|
308
|
+
? "Registered (will run at next logon)"
|
|
309
|
+
: "Not configured",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Public API ─────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/** Enable auto-start for the current platform */
|
|
316
|
+
export async function enableAutoStart(config: AutoStartConfig): Promise<string> {
|
|
317
|
+
const platform = process.platform;
|
|
318
|
+
if (platform === "darwin") return enableMacOS(config);
|
|
319
|
+
if (platform === "linux") return enableLinux(config);
|
|
320
|
+
if (platform === "win32") return enableWindows(config);
|
|
321
|
+
throw new Error(`Auto-start not supported on ${platform}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Disable auto-start for the current platform */
|
|
325
|
+
export async function disableAutoStart(): Promise<void> {
|
|
326
|
+
const platform = process.platform;
|
|
327
|
+
if (platform === "darwin") return disableMacOS();
|
|
328
|
+
if (platform === "linux") return disableLinux();
|
|
329
|
+
if (platform === "win32") return disableWindows();
|
|
330
|
+
throw new Error(`Auto-start not supported on ${platform}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Get auto-start status for the current platform */
|
|
334
|
+
export function getAutoStartStatus(): AutoStartStatus {
|
|
335
|
+
const platform = process.platform;
|
|
336
|
+
if (platform === "darwin") return statusMacOS();
|
|
337
|
+
if (platform === "linux") return statusLinux();
|
|
338
|
+
if (platform === "win32") return statusWindows();
|
|
339
|
+
return {
|
|
340
|
+
enabled: false,
|
|
341
|
+
running: false,
|
|
342
|
+
platform: `${platform} (unsupported)`,
|
|
343
|
+
servicePath: null,
|
|
344
|
+
details: `Auto-start not supported on ${platform}`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export { loadMetadata, METADATA_FILE };
|
package/test-claude-oauth-v2.mjs
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Claude OAuth PKCE test — fixed version
|
|
4
|
-
*
|
|
5
|
-
* Fixes vs v1:
|
|
6
|
-
* 1. Token endpoint: console.anthropic.com (not api.anthropic.com)
|
|
7
|
-
* 2. Content-Type: application/x-www-form-urlencoded (not JSON)
|
|
8
|
-
* 3. Removed `state` from token exchange body (client-side CSRF only)
|
|
9
|
-
* 4. Only platform.claude.com redirect (confirmed to work with this client_id)
|
|
10
|
-
*
|
|
11
|
-
* Usage: bun test-claude-oauth-v2.mjs
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import crypto from 'crypto'
|
|
15
|
-
import readline from 'readline'
|
|
16
|
-
import { execSync } from 'child_process'
|
|
17
|
-
|
|
18
|
-
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
19
|
-
const REDIRECT = 'https://platform.claude.com/oauth/code/callback'
|
|
20
|
-
const SCOPE = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload'
|
|
21
|
-
const TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'
|
|
22
|
-
|
|
23
|
-
// ── PKCE ────────────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
function generatePKCE() {
|
|
26
|
-
// 32 random bytes → 43 base64url chars (RFC 7636 minimum entropy)
|
|
27
|
-
const verifier = crypto.randomBytes(32).toString('base64url')
|
|
28
|
-
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
|
|
29
|
-
const state = crypto.randomBytes(16).toString('base64url')
|
|
30
|
-
return { verifier, challenge, state }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
function copyToClipboard(text) {
|
|
36
|
-
try { execSync(`echo ${JSON.stringify(text)} | pbcopy`); return true }
|
|
37
|
-
catch { return false }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function ask(rl, question) {
|
|
41
|
-
return new Promise(resolve => rl.question(question, resolve))
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function parseCode(input) {
|
|
45
|
-
input = input.trim()
|
|
46
|
-
// Full URL
|
|
47
|
-
try {
|
|
48
|
-
const url = new URL(input)
|
|
49
|
-
const code = url.searchParams.get('code')
|
|
50
|
-
if (code) return code.split('#')[0]
|
|
51
|
-
} catch {}
|
|
52
|
-
// platform.claude.com format: CODE#STATE
|
|
53
|
-
if (input.includes('#')) return input.split('#')[0]
|
|
54
|
-
// raw code
|
|
55
|
-
return input.split('?')[0].split('&')[0]
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ── Token exchange ───────────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
async function exchangeCode({ code, verifier }) {
|
|
61
|
-
console.log(`\nPOST ${TOKEN_URL}`)
|
|
62
|
-
|
|
63
|
-
const body = new URLSearchParams({
|
|
64
|
-
grant_type: 'authorization_code',
|
|
65
|
-
client_id: CLIENT_ID,
|
|
66
|
-
code,
|
|
67
|
-
redirect_uri: REDIRECT,
|
|
68
|
-
code_verifier: verifier,
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
const res = await fetch(TOKEN_URL, {
|
|
72
|
-
method: 'POST',
|
|
73
|
-
headers: {
|
|
74
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
75
|
-
'Accept': 'application/json',
|
|
76
|
-
},
|
|
77
|
-
body,
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
const data = await res.json()
|
|
81
|
-
return { ok: res.ok, status: res.status, data }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
87
|
-
|
|
88
|
-
console.log('\n╔══════════════════════════════════════════╗')
|
|
89
|
-
console.log('║ Claude OAuth PKCE Test — v2 (fixed) ║')
|
|
90
|
-
console.log('╚══════════════════════════════════════════╝\n')
|
|
91
|
-
|
|
92
|
-
const { verifier, challenge, state } = generatePKCE()
|
|
93
|
-
|
|
94
|
-
console.log(`verifier (${verifier.length} chars): ${verifier.slice(0, 20)}...`)
|
|
95
|
-
console.log(`challenge (${challenge.length} chars): ${challenge}`)
|
|
96
|
-
console.log(`state (${state.length} chars): ${state}\n`)
|
|
97
|
-
|
|
98
|
-
// Build auth URL — code=true tells claude.ai to show a code page instead of redirecting
|
|
99
|
-
const params = new URLSearchParams({
|
|
100
|
-
code: 'true',
|
|
101
|
-
client_id: CLIENT_ID,
|
|
102
|
-
response_type: 'code',
|
|
103
|
-
redirect_uri: REDIRECT,
|
|
104
|
-
scope: SCOPE,
|
|
105
|
-
code_challenge: challenge,
|
|
106
|
-
code_challenge_method: 'S256',
|
|
107
|
-
state,
|
|
108
|
-
})
|
|
109
|
-
const authUrl = `https://claude.ai/oauth/authorize?${params}`
|
|
110
|
-
|
|
111
|
-
console.log('┌─ Authorization URL ──────────────────────────────────────────────')
|
|
112
|
-
console.log(`│ ${authUrl}`)
|
|
113
|
-
console.log('└─────────────────────────────────────────────────────────────────\n')
|
|
114
|
-
|
|
115
|
-
const copied = copyToClipboard(authUrl)
|
|
116
|
-
if (copied) console.log('✓ Copied to clipboard\n')
|
|
117
|
-
|
|
118
|
-
console.log('1. Mở URL trong browser')
|
|
119
|
-
console.log('2. Login / approve')
|
|
120
|
-
console.log('3. platform.claude.com sẽ hiển thị code dạng: XXXX#STATE\n')
|
|
121
|
-
|
|
122
|
-
const input = await ask(rl, 'Paste code/URL: ')
|
|
123
|
-
const code = parseCode(input)
|
|
124
|
-
|
|
125
|
-
if (!code) {
|
|
126
|
-
console.error('❌ Không parse được code')
|
|
127
|
-
rl.close()
|
|
128
|
-
process.exit(1)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
console.log(`\n✓ code = ${code.slice(0, 30)}...`)
|
|
132
|
-
|
|
133
|
-
// Validate state (CSRF check — client-side only, not sent to server)
|
|
134
|
-
const returnedState = input.includes('#') ? input.split('#')[1]?.split('&')[0] : null
|
|
135
|
-
if (returnedState && returnedState !== state) {
|
|
136
|
-
console.warn(`⚠️ State mismatch! Expected: ${state}, Got: ${returnedState}`)
|
|
137
|
-
} else if (returnedState) {
|
|
138
|
-
console.log(`✓ state OK`)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
console.log('\nExchanging code for token...')
|
|
142
|
-
const { ok, status, data } = await exchangeCode({ code, verifier })
|
|
143
|
-
|
|
144
|
-
console.log(`\nHTTP ${status}`)
|
|
145
|
-
|
|
146
|
-
if (ok) {
|
|
147
|
-
console.log('\n✅ SUCCESS!')
|
|
148
|
-
console.log('─'.repeat(50))
|
|
149
|
-
console.log(`access_token: ${(data.access_token ?? data.accessToken)?.slice(0, 35)}...`)
|
|
150
|
-
console.log(`refresh_token: ${(data.refresh_token ?? data.refreshToken)?.slice(0, 35)}...`)
|
|
151
|
-
console.log(`token_type: ${data.token_type}`)
|
|
152
|
-
console.log(`expires_in: ${data.expires_in}s`)
|
|
153
|
-
if (data.account) {
|
|
154
|
-
console.log(`email: ${data.account.email_address}`)
|
|
155
|
-
console.log(`account_uuid: ${data.account.uuid}`)
|
|
156
|
-
}
|
|
157
|
-
console.log('─'.repeat(50))
|
|
158
|
-
console.log('\nFull response:')
|
|
159
|
-
console.log(JSON.stringify(data, null, 2))
|
|
160
|
-
} else {
|
|
161
|
-
console.log('\n❌ FAILED')
|
|
162
|
-
console.log(JSON.stringify(data, null, 2))
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
rl.close()
|
package/test-claude-oauth.mjs
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Test Claude OAuth flow — manual copy/paste approach
|
|
4
|
-
* Usage: bun test-claude-oauth.mjs
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import crypto from 'crypto'
|
|
8
|
-
import readline from 'readline'
|
|
9
|
-
import { execSync } from 'child_process'
|
|
10
|
-
|
|
11
|
-
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
12
|
-
const SCOPE = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload'
|
|
13
|
-
|
|
14
|
-
// Redirect URIs to test (in order)
|
|
15
|
-
const REDIRECT_URI_OPTIONS = [
|
|
16
|
-
{ label: 'platform.claude.com (manual code page)', uri: 'https://platform.claude.com/oauth/code/callback' },
|
|
17
|
-
{ label: 'localhost:54545 (CLIProxyAPI style)', uri: 'http://localhost:54545/callback' },
|
|
18
|
-
{ label: 'localhost (no port)', uri: 'http://localhost/callback' },
|
|
19
|
-
]
|
|
20
|
-
|
|
21
|
-
// Token endpoints to try
|
|
22
|
-
const TOKEN_ENDPOINTS = [
|
|
23
|
-
'https://api.anthropic.com/v1/oauth/token',
|
|
24
|
-
'https://claude.ai/api/oauth/token',
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
// ── PKCE ────────────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
function generatePKCE() {
|
|
30
|
-
// 96 bytes → 128 base64url chars (matches CLIProxyAPI exactly)
|
|
31
|
-
const verifierBytes = crypto.randomBytes(96)
|
|
32
|
-
const verifier = verifierBytes.toString('base64url')
|
|
33
|
-
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
|
|
34
|
-
const state = crypto.randomBytes(16).toString('base64url')
|
|
35
|
-
return { verifier, challenge, state }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ── CLI helpers ──────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
function copyToClipboard(text) {
|
|
41
|
-
try {
|
|
42
|
-
execSync(`echo ${JSON.stringify(text)} | pbcopy`)
|
|
43
|
-
return true
|
|
44
|
-
} catch { return false }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function ask(rl, question) {
|
|
48
|
-
return new Promise(resolve => rl.question(question, resolve))
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function parseCode(input) {
|
|
52
|
-
input = input.trim()
|
|
53
|
-
// Full URL: http://localhost/callback?code=XXX&state=YYY
|
|
54
|
-
try {
|
|
55
|
-
const url = new URL(input)
|
|
56
|
-
const code = url.searchParams.get('code')
|
|
57
|
-
if (code) return code.split('#')[0]
|
|
58
|
-
} catch {}
|
|
59
|
-
// code#state format (platform.claude.com)
|
|
60
|
-
if (input.includes('#')) return input.split('#')[0]
|
|
61
|
-
// Raw code
|
|
62
|
-
return input.split('?')[0].split('&')[0]
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ── Token exchange ───────────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
async function exchangeCode({ code, verifier, state, redirectUri }) {
|
|
68
|
-
const body = {
|
|
69
|
-
grant_type: 'authorization_code',
|
|
70
|
-
client_id: CLIENT_ID,
|
|
71
|
-
code,
|
|
72
|
-
redirect_uri: redirectUri,
|
|
73
|
-
code_verifier: verifier,
|
|
74
|
-
state,
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
for (const endpoint of TOKEN_ENDPOINTS) {
|
|
78
|
-
process.stdout.write(` → POST ${endpoint} ... `)
|
|
79
|
-
const res = await fetch(endpoint, {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
82
|
-
body: JSON.stringify(body),
|
|
83
|
-
})
|
|
84
|
-
const data = await res.json()
|
|
85
|
-
console.log(`HTTP ${res.status}`)
|
|
86
|
-
|
|
87
|
-
if (res.ok) return { endpoint, data }
|
|
88
|
-
console.log(` error: ${JSON.stringify(data?.error || data)}`)
|
|
89
|
-
}
|
|
90
|
-
return null
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
96
|
-
|
|
97
|
-
console.log('\n╔══════════════════════════════════════════╗')
|
|
98
|
-
console.log('║ Claude OAuth Test — PPM ║')
|
|
99
|
-
console.log('╚══════════════════════════════════════════╝\n')
|
|
100
|
-
|
|
101
|
-
// Pick redirect URI
|
|
102
|
-
console.log('Redirect URI options:')
|
|
103
|
-
REDIRECT_URI_OPTIONS.forEach((o, i) => console.log(` ${i + 1}. ${o.label}`))
|
|
104
|
-
const choice = parseInt(await ask(rl, '\nChọn (1-3) [default: 1]: ') || '1') - 1
|
|
105
|
-
const { uri: redirectUri, label } = REDIRECT_URI_OPTIONS[choice] || REDIRECT_URI_OPTIONS[0]
|
|
106
|
-
console.log(`✓ Using: ${redirectUri}\n`)
|
|
107
|
-
|
|
108
|
-
// Generate PKCE + URL
|
|
109
|
-
const { verifier, challenge, state } = generatePKCE()
|
|
110
|
-
console.log(`code_verifier length: ${verifier.length} chars`)
|
|
111
|
-
|
|
112
|
-
const params = new URLSearchParams({
|
|
113
|
-
client_id: CLIENT_ID,
|
|
114
|
-
response_type: 'code',
|
|
115
|
-
redirect_uri: redirectUri,
|
|
116
|
-
scope: SCOPE,
|
|
117
|
-
code_challenge: challenge,
|
|
118
|
-
code_challenge_method: 'S256',
|
|
119
|
-
state,
|
|
120
|
-
prompt: 'login',
|
|
121
|
-
})
|
|
122
|
-
// platform.claude.com redirect uses special `code=true` trigger
|
|
123
|
-
if (redirectUri.includes('platform.claude.com')) {
|
|
124
|
-
params.set('code', 'true')
|
|
125
|
-
}
|
|
126
|
-
const authUrl = `https://claude.ai/oauth/authorize?${params}`
|
|
127
|
-
|
|
128
|
-
console.log('\n┌─ Authorization URL ──────────────────────────────────────────────')
|
|
129
|
-
console.log(`│ ${authUrl}`)
|
|
130
|
-
console.log('└─────────────────────────────────────────────────────────────────\n')
|
|
131
|
-
|
|
132
|
-
const copied = copyToClipboard(authUrl)
|
|
133
|
-
if (copied) console.log('✓ Copied to clipboard (pbcopy)\n')
|
|
134
|
-
|
|
135
|
-
console.log('Mở URL trong browser → authorize → copy code/URL trả về\n')
|
|
136
|
-
if (label.includes('platform')) {
|
|
137
|
-
console.log(' platform.claude.com sẽ hiện code dạng: XXXX#STATE')
|
|
138
|
-
} else {
|
|
139
|
-
console.log(' Browser sẽ redirect về localhost (lỗi kết nối là bình thường)')
|
|
140
|
-
console.log(' Copy toàn bộ URL từ address bar: http://localhost.../callback?code=XXX')
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const input = await ask(rl, '\nPaste code/URL: ')
|
|
144
|
-
const code = parseCode(input)
|
|
145
|
-
if (!code) { console.error('❌ Không parse được code'); process.exit(1) }
|
|
146
|
-
console.log(`✓ code = ${code.slice(0, 20)}...\n`)
|
|
147
|
-
|
|
148
|
-
// Exchange
|
|
149
|
-
console.log('Thử exchange token:')
|
|
150
|
-
const result = await exchangeCode({ code, verifier, state, redirectUri })
|
|
151
|
-
|
|
152
|
-
if (result) {
|
|
153
|
-
const { endpoint, data } = result
|
|
154
|
-
console.log(`\n✅ Success! Endpoint: ${endpoint}`)
|
|
155
|
-
console.log('\n── Token info ──────────────────────────────────────')
|
|
156
|
-
console.log(` access_token: ${data.access_token?.slice(0, 30)}...`)
|
|
157
|
-
console.log(` refresh_token: ${data.refresh_token?.slice(0, 30)}...`)
|
|
158
|
-
console.log(` token_type: ${data.token_type}`)
|
|
159
|
-
console.log(` expires_in: ${data.expires_in}s`)
|
|
160
|
-
if (data.account) {
|
|
161
|
-
console.log(` email: ${data.account.email_address}`)
|
|
162
|
-
console.log(` account_uuid: ${data.account.uuid}`)
|
|
163
|
-
}
|
|
164
|
-
if (data.organization) {
|
|
165
|
-
console.log(` org_name: ${data.organization.name}`)
|
|
166
|
-
console.log(` org_uuid: ${data.organization.uuid}`)
|
|
167
|
-
}
|
|
168
|
-
console.log('────────────────────────────────────────────────────')
|
|
169
|
-
console.log('\nFull response:')
|
|
170
|
-
console.log(JSON.stringify(data, null, 2))
|
|
171
|
-
} else {
|
|
172
|
-
console.log('\n❌ Tất cả endpoints đều fail')
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
rl.close()
|
package/test-verify-oat.mjs
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Test verify sk-ant-oat token — see what APIs return
|
|
4
|
-
* Usage: bun test-verify-oat.mjs [TOKEN]
|
|
5
|
-
* If no TOKEN arg, reads TEST_OAUTH_TOKEN_1 from .env.test
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
|
-
import { resolve, dirname } from "node:path";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
|
-
|
|
12
|
-
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
|
|
14
|
-
// Token from CLI arg or .env.test
|
|
15
|
-
let token = process.argv[2];
|
|
16
|
-
if (!token) {
|
|
17
|
-
const envPath = resolve(__dir, ".env.test");
|
|
18
|
-
const env = Object.fromEntries(
|
|
19
|
-
readFileSync(envPath, "utf-8")
|
|
20
|
-
.split("\n")
|
|
21
|
-
.filter((l) => l && !l.startsWith("#"))
|
|
22
|
-
.map((l) => l.split("=").map((s) => s.trim()))
|
|
23
|
-
.filter(([k, v]) => k && v),
|
|
24
|
-
);
|
|
25
|
-
token = env.TEST_OAUTH_TOKEN_1;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (!token || token.startsWith("REPLACE")) {
|
|
29
|
-
console.error("Usage: bun test-verify-oat.mjs [TOKEN]");
|
|
30
|
-
process.exit(1);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
console.log(`Token: ${token.slice(0, 30)}...`);
|
|
34
|
-
|
|
35
|
-
// ── Helper ──────────────────────────────────────────────
|
|
36
|
-
async function tryEndpoint({ label, url, method = "GET", headers = {}, body }) {
|
|
37
|
-
console.log(`\n── ${label} ──`);
|
|
38
|
-
try {
|
|
39
|
-
const opts = {
|
|
40
|
-
method,
|
|
41
|
-
headers: {
|
|
42
|
-
Accept: "application/json",
|
|
43
|
-
Authorization: `Bearer ${token}`,
|
|
44
|
-
...headers,
|
|
45
|
-
},
|
|
46
|
-
signal: AbortSignal.timeout(10_000),
|
|
47
|
-
};
|
|
48
|
-
if (body) opts.body = typeof body === "string" ? body : JSON.stringify(body);
|
|
49
|
-
const res = await fetch(url, opts);
|
|
50
|
-
const text = await res.text();
|
|
51
|
-
console.log(`HTTP ${res.status}`);
|
|
52
|
-
try {
|
|
53
|
-
console.log(JSON.stringify(JSON.parse(text), null, 2));
|
|
54
|
-
} catch {
|
|
55
|
-
console.log(text.slice(0, 500));
|
|
56
|
-
}
|
|
57
|
-
} catch (e) {
|
|
58
|
-
console.log(`ERROR: ${e.message}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ── 1. claude auth status (CLI) ─────────────────────────
|
|
63
|
-
console.log("\n── claude auth status (CLI) ──");
|
|
64
|
-
try {
|
|
65
|
-
const proc = Bun.spawn(["claude", "auth", "status"], {
|
|
66
|
-
env: { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: token, ANTHROPIC_API_KEY: "" },
|
|
67
|
-
stdout: "pipe",
|
|
68
|
-
stderr: "pipe",
|
|
69
|
-
});
|
|
70
|
-
const stdout = await new Response(proc.stdout).text();
|
|
71
|
-
const stderr = await new Response(proc.stderr).text();
|
|
72
|
-
await proc.exited;
|
|
73
|
-
console.log("stdout:", stdout);
|
|
74
|
-
if (stderr) console.log("stderr:", stderr);
|
|
75
|
-
} catch (e) {
|
|
76
|
-
console.log(`ERROR: ${e.message}`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ── 2. /api/oauth/usage ─────────────────────────────────
|
|
80
|
-
await tryEndpoint({
|
|
81
|
-
label: "GET /api/oauth/usage",
|
|
82
|
-
url: "https://api.anthropic.com/api/oauth/usage",
|
|
83
|
-
headers: { "anthropic-beta": "oauth-2025-04-20", "User-Agent": "ppm/1.0" },
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// ── 3. /v1/organizations (org info) ─────────────────────
|
|
87
|
-
await tryEndpoint({
|
|
88
|
-
label: "GET /v1/organizations",
|
|
89
|
-
url: "https://api.anthropic.com/v1/organizations",
|
|
90
|
-
headers: { "anthropic-version": "2023-06-01" },
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// ── 4. /api/auth/session (claude.ai session) ────────────
|
|
94
|
-
await tryEndpoint({
|
|
95
|
-
label: "GET /api/auth/session (claude.ai)",
|
|
96
|
-
url: "https://claude.ai/api/auth/session",
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// ── 5. /v1/messages (minimal — test if token works) ─────
|
|
100
|
-
await tryEndpoint({
|
|
101
|
-
label: "POST /v1/messages (1 token test)",
|
|
102
|
-
url: "https://api.anthropic.com/v1/messages",
|
|
103
|
-
method: "POST",
|
|
104
|
-
headers: { "Content-Type": "application/json", "anthropic-version": "2023-06-01" },
|
|
105
|
-
body: { model: "claude-sonnet-4-20250514", max_tokens: 1, messages: [{ role: "user", content: "hi" }] },
|
|
106
|
-
});
|