syndai 0.0.0 → 0.1.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 +60 -3
- package/bin/syndai.js +33 -0
- package/package.json +26 -7
- package/src/api.js +70 -0
- package/src/claude.js +145 -0
- package/src/codex.js +71 -0
- package/src/connect.js +122 -0
- package/src/proc.js +36 -0
- package/src/prompt.js +43 -0
- package/src/tokens.js +45 -0
- package/cli.js +0 -2
package/README.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
|
-
# syndai
|
|
1
|
+
# syndai connect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect your **Claude** and **ChatGPT** plans to a Syndai workspace with one
|
|
4
|
+
command:
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
```bash
|
|
7
|
+
npx -y syndai connect <code>
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Get `<code>` from your Syndai portal → **Connections → Connect a plan**. It is a
|
|
11
|
+
single-use code with a 15-minute TTL.
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
1. **Claims** the code and shows you where it will connect:
|
|
16
|
+
`Connect to workspace "Acme" (s••@acme.com)? Phrase: amber-falcon-42 [y/N]` —
|
|
17
|
+
default **No**, no `--yes` flag. Confirm the phrase matches what the portal
|
|
18
|
+
shows, then the portal owner clicks **Approve**.
|
|
19
|
+
2. **Claude:** if `claude` is on your PATH, mints a fresh plan token with
|
|
20
|
+
`claude setup-token` (under a PTY, so its full-screen UI works) and verifies it
|
|
21
|
+
with the official CLI before uploading. If `claude` is not installed, it tells
|
|
22
|
+
you to run `claude setup-token` yourself and paste the `sk-ant-oat01-…` value —
|
|
23
|
+
the paste is hidden.
|
|
24
|
+
3. **ChatGPT:** if `codex` is on your PATH, runs `codex login` into a throwaway
|
|
25
|
+
`CODEX_HOME` (Syndai keeps its own login lineage — copying your laptop's
|
|
26
|
+
`auth.json` would log one of you out), captures the resulting `auth.json`, and
|
|
27
|
+
deletes the temp directory.
|
|
28
|
+
4. **Uploads** whatever it collected in exactly one call. The portal flips to
|
|
29
|
+
**Connected ✓**.
|
|
30
|
+
|
|
31
|
+
One code connects both providers if both CLIs are present.
|
|
32
|
+
|
|
33
|
+
## It stores nothing, and echoes no token
|
|
34
|
+
|
|
35
|
+
The tool holds your token only in memory for the single upload. It never writes it
|
|
36
|
+
to disk, never puts it on the clipboard, and never prints it to your terminal or
|
|
37
|
+
scrollback. Pasted tokens are read with terminal echo off.
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
- `SYNDAI_API_URL` — override the API base (default `https://api.syndai.ai`).
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
- Node.js **>= 18** (dependency-free — Node builtins only).
|
|
46
|
+
- `claude` and/or `codex` on your PATH for the one-command path; otherwise the
|
|
47
|
+
Claude paste fallback still works.
|
|
48
|
+
|
|
49
|
+
## Source & provenance
|
|
50
|
+
|
|
51
|
+
This package is developed in the Syndai monorepo under `tools/syndai-connect/`
|
|
52
|
+
and **mirrored to the public repo
|
|
53
|
+
[`solozerolabs/syndai-connect`](https://github.com/solozerolabs/syndai-connect)**
|
|
54
|
+
for the npm provenance publish (owner action **O1**) — a private monorepo cannot
|
|
55
|
+
publish with sigstore provenance. Published as `syndai` with
|
|
56
|
+
`publishConfig.provenance`.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
node --test test/ # pure-helper unit tests (no network, no subprocess)
|
|
62
|
+
```
|
package/bin/syndai.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entrypoint for the `syndai` CLI. One subcommand today: `connect <code>`.
|
|
3
|
+
|
|
4
|
+
import { runConnect } from "../src/connect.js";
|
|
5
|
+
|
|
6
|
+
async function main(argv) {
|
|
7
|
+
const [subcommand, ...rest] = argv;
|
|
8
|
+
switch (subcommand) {
|
|
9
|
+
case "connect":
|
|
10
|
+
return runConnect(rest[0]);
|
|
11
|
+
case undefined:
|
|
12
|
+
case "-h":
|
|
13
|
+
case "--help":
|
|
14
|
+
case "help":
|
|
15
|
+
console.log("syndai — connect your Claude and ChatGPT plans to a Syndai workspace");
|
|
16
|
+
console.log("");
|
|
17
|
+
console.log("usage: npx -y syndai connect <code>");
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log("Get <code> from your Syndai portal: Connections → Connect a plan.");
|
|
20
|
+
return subcommand ? 0 : 2;
|
|
21
|
+
default:
|
|
22
|
+
console.error(`syndai: unknown command "${subcommand}"`);
|
|
23
|
+
console.error("usage: npx -y syndai connect <code>");
|
|
24
|
+
return 2;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
main(process.argv.slice(2))
|
|
29
|
+
.then((code) => process.exit(code || 0))
|
|
30
|
+
.catch((e) => {
|
|
31
|
+
console.error(`syndai: ${e && e.message ? e.message : e}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
});
|
package/package.json
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "syndai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Connect your Claude
|
|
5
|
-
"
|
|
6
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Connect your Claude and ChatGPT plans to a Syndai workspace with one command. Stores nothing locally; never echoes a token.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"syndai": "bin/syndai.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/solozerolabs/syndai-connect.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/solozerolabs/syndai-connect#readme",
|
|
7
17
|
"license": "MIT",
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
18
|
+
"files": [
|
|
19
|
+
"bin/",
|
|
20
|
+
"src/",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"provenance": true,
|
|
28
|
+
"access": "public"
|
|
29
|
+
}
|
|
11
30
|
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Minimal JSON-over-HTTPS client for the pairing routes, built on node:https so
|
|
2
|
+
// the package stays dependency-free. Every helper returns { status, body } where
|
|
3
|
+
// body is the parsed JSON (or the raw string when it isn't JSON) — callers decide
|
|
4
|
+
// what a non-2xx means, per route.
|
|
5
|
+
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import { URL } from "node:url";
|
|
9
|
+
|
|
10
|
+
// Production API host. Overridable with SYNDAI_API_URL for staging/dev. Matches
|
|
11
|
+
// tools/syndai-pickup (_DEFAULT_API_BASE) and apps/portal (NEXT_PUBLIC_API_BASE).
|
|
12
|
+
export function apiBase() {
|
|
13
|
+
return (process.env.SYNDAI_API_URL || "https://api.syndai.ai").replace(/\/+$/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function request(method, path, payload) {
|
|
17
|
+
const url = new URL(apiBase() + path);
|
|
18
|
+
const body = payload === undefined ? undefined : JSON.stringify(payload);
|
|
19
|
+
const transport = url.protocol === "http:" ? http : https;
|
|
20
|
+
const options = {
|
|
21
|
+
method,
|
|
22
|
+
hostname: url.hostname,
|
|
23
|
+
port: url.port || undefined,
|
|
24
|
+
path: url.pathname + url.search,
|
|
25
|
+
headers: {
|
|
26
|
+
accept: "application/json",
|
|
27
|
+
"user-agent": "syndai-connect",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
if (body !== undefined) {
|
|
31
|
+
options.headers["content-type"] = "application/json";
|
|
32
|
+
options.headers["content-length"] = Buffer.byteLength(body);
|
|
33
|
+
}
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const req = transport.request(options, (res) => {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
res.on("data", (c) => chunks.push(c));
|
|
38
|
+
res.on("end", () => {
|
|
39
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
40
|
+
let parsed = text;
|
|
41
|
+
try {
|
|
42
|
+
parsed = text ? JSON.parse(text) : {};
|
|
43
|
+
} catch {
|
|
44
|
+
/* leave as raw text */
|
|
45
|
+
}
|
|
46
|
+
resolve({ status: res.statusCode || 0, body: parsed });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
req.on("error", reject);
|
|
50
|
+
if (body !== undefined) req.write(body);
|
|
51
|
+
req.end();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function post(path, payload) {
|
|
56
|
+
return request("POST", path, payload);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True for any 2xx status. */
|
|
60
|
+
export function ok(status) {
|
|
61
|
+
return status >= 200 && status < 300;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Best-effort human message out of a JSON error body. */
|
|
65
|
+
export function errorMessage(body) {
|
|
66
|
+
if (body && typeof body === "object") {
|
|
67
|
+
return body.detail || body.message || body.error || JSON.stringify(body);
|
|
68
|
+
}
|
|
69
|
+
return String(body || "unknown error");
|
|
70
|
+
}
|
package/src/claude.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Claude plan connect: mint a fresh setup-token, verify it with the official CLI,
|
|
2
|
+
// or fall back to a hidden paste when `claude` isn't installed. The token is never
|
|
3
|
+
// printed, never written to disk, never put on the clipboard.
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { extractOatToken, stripInternalWhitespace } from "./tokens.js";
|
|
10
|
+
import { onPath, isBsdScript } from "./proc.js";
|
|
11
|
+
import { readHidden } from "./prompt.js";
|
|
12
|
+
|
|
13
|
+
// `claude setup-token` draws a full-screen terminal UI, so it needs a PTY; we get
|
|
14
|
+
// one from script(1). BSD (macOS) and util-linux take different argument shapes —
|
|
15
|
+
// both handled. Injected in tests via `deps` so the pure extraction stays testable.
|
|
16
|
+
export function buildScriptArgv() {
|
|
17
|
+
if (isBsdScript()) {
|
|
18
|
+
return ["script", ["-q", "/dev/null", "claude", "setup-token"]];
|
|
19
|
+
}
|
|
20
|
+
return ["script", ["-qec", "claude setup-token", "/dev/null"]];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runSetupToken(deps) {
|
|
24
|
+
const [cmd, args] = deps.buildScriptArgv();
|
|
25
|
+
const res = deps.spawnSync(cmd, args, { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
|
|
26
|
+
const out = (res.stdout || "") + (res.stderr || "");
|
|
27
|
+
return deps.extractOatToken(out);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify a Claude plan token by asking the official CLI to answer a one-word
|
|
32
|
+
* prompt. A rate-limited token still exits 0, so we DO NOT trust the exit code —
|
|
33
|
+
* success is a `result` frame with `is_error === false`. A throwaway
|
|
34
|
+
* CLAUDE_CONFIG_DIR and blanked ANTHROPIC_* keep the check hermetic and force the
|
|
35
|
+
* token (not a stray key) to authenticate.
|
|
36
|
+
*/
|
|
37
|
+
export function verifyClaudeToken(token, deps = {}) {
|
|
38
|
+
const _spawnSync = deps.spawnSync || spawnSync;
|
|
39
|
+
const _mkdtemp = deps.mkdtempSync || ((p) => fs.mkdtempSync(p));
|
|
40
|
+
const configDir = _mkdtemp(path.join(os.tmpdir(), "syndai-claude-"));
|
|
41
|
+
try {
|
|
42
|
+
const res = _spawnSync("claude", ["-p", "ping", "--output-format", "json"], {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
env: {
|
|
46
|
+
...process.env,
|
|
47
|
+
CLAUDE_CODE_OAUTH_TOKEN: token,
|
|
48
|
+
ANTHROPIC_API_KEY: "",
|
|
49
|
+
ANTHROPIC_AUTH_TOKEN: "",
|
|
50
|
+
ANTHROPIC_BASE_URL: "",
|
|
51
|
+
CLAUDE_CONFIG_DIR: configDir,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return resultIsError(res.stdout) === false;
|
|
55
|
+
} finally {
|
|
56
|
+
try {
|
|
57
|
+
fs.rmSync(configDir, { recursive: true, force: true });
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Parse `claude -p --output-format json` stdout and report the result frame's
|
|
65
|
+
// is_error. Returns true/false when found, or null when no result frame parsed.
|
|
66
|
+
export function resultIsError(stdout) {
|
|
67
|
+
const text = String(stdout || "").trim();
|
|
68
|
+
if (!text) return null;
|
|
69
|
+
// Try a single JSON object first, then fall back to JSON-lines (stream-json).
|
|
70
|
+
const candidates = [];
|
|
71
|
+
try {
|
|
72
|
+
candidates.push(JSON.parse(text));
|
|
73
|
+
} catch {
|
|
74
|
+
for (const line of text.split(/\r?\n/)) {
|
|
75
|
+
const t = line.trim();
|
|
76
|
+
if (!t) continue;
|
|
77
|
+
try {
|
|
78
|
+
candidates.push(JSON.parse(t));
|
|
79
|
+
} catch {
|
|
80
|
+
/* skip noise */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const frame of candidates) {
|
|
85
|
+
if (frame && typeof frame === "object" && (frame.type === "result" || "is_error" in frame)) {
|
|
86
|
+
return Boolean(frame.is_error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Obtain a verified Claude plan token, or null if the user declines / it fails.
|
|
94
|
+
* - `claude` on PATH: mint via setup-token (PTY), then verify.
|
|
95
|
+
* - not installed: print the paste-fallback instruction, read a HIDDEN paste in
|
|
96
|
+
* this same command, strip wrapping whitespace, then verify.
|
|
97
|
+
* The token is returned to the caller for the single upload; nothing else touches it.
|
|
98
|
+
*/
|
|
99
|
+
export async function connectClaude(deps = {}) {
|
|
100
|
+
const _onPath = deps.onPath || onPath;
|
|
101
|
+
const _readHidden = deps.readHidden || readHidden;
|
|
102
|
+
const log = deps.log || console.log;
|
|
103
|
+
|
|
104
|
+
const scriptOnPath = deps.scriptOnPath ?? _onPath("script");
|
|
105
|
+
|
|
106
|
+
if (_onPath("claude") && scriptOnPath) {
|
|
107
|
+
log("Claude: minting a fresh plan token (approve in your browser when Claude asks)…");
|
|
108
|
+
const token = runSetupToken({
|
|
109
|
+
buildScriptArgv: deps.buildScriptArgv || buildScriptArgv,
|
|
110
|
+
spawnSync: deps.spawnSync || spawnSync,
|
|
111
|
+
extractOatToken,
|
|
112
|
+
});
|
|
113
|
+
if (!token) {
|
|
114
|
+
log("Claude: could not read a token from setup-token. Skipping Claude.");
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
log("Claude: verifying the token with the official CLI…");
|
|
118
|
+
if (!verifyClaudeToken(token, deps)) {
|
|
119
|
+
log("Claude: the minted token did not verify. Skipping Claude.");
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
log("Claude: token verified ✓");
|
|
123
|
+
return token;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Paste fallback (no `claude`, or no PTY via script(1) — e.g. Windows w/o WSL).
|
|
127
|
+
log("");
|
|
128
|
+
log("Claude CLI not found. To connect your Claude plan, in another terminal run:");
|
|
129
|
+
log(" claude setup-token");
|
|
130
|
+
log("then paste the sk-ant-oat01-… value below (it is hidden and not stored):");
|
|
131
|
+
const raw = await _readHidden("Claude plan token: ");
|
|
132
|
+
const token = stripInternalWhitespace(raw);
|
|
133
|
+
if (!token) {
|
|
134
|
+
log("Claude: nothing pasted. Skipping Claude.");
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (deps.onPath ? _onPath("claude") : onPath("claude")) {
|
|
138
|
+
// If claude showed up we can still verify; otherwise trust the server verify.
|
|
139
|
+
if (!verifyClaudeToken(token, deps)) {
|
|
140
|
+
log("Claude: that token did not verify. Skipping Claude.");
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return token;
|
|
145
|
+
}
|
package/src/codex.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ChatGPT plan connect: give Syndai its OWN Codex login lineage — `codex login`
|
|
2
|
+
// into a throwaway CODEX_HOME, confirm, read the resulting auth.json, then delete
|
|
3
|
+
// the directory. Copying the laptop's ~/.codex/auth.json is deliberately NOT done:
|
|
4
|
+
// two machines sharing one rotating refresh token log each other out (spec §Q3a).
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { onPath } from "./proc.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run the Codex own-lineage login and return the parsed auth.json OBJECT, or null if the
|
|
14
|
+
* user declines / it fails. The temp CODEX_HOME is always removed. OPENAI_API_KEY
|
|
15
|
+
* is unset so a stray key can't shadow the plan login; stdin is closed for any
|
|
16
|
+
* non-interactive step.
|
|
17
|
+
*/
|
|
18
|
+
export function connectCodex(deps = {}) {
|
|
19
|
+
const _onPath = deps.onPath || onPath;
|
|
20
|
+
const _spawnSync = deps.spawnSync || spawnSync;
|
|
21
|
+
const log = deps.log || console.log;
|
|
22
|
+
|
|
23
|
+
if (!_onPath("codex")) {
|
|
24
|
+
return null; // no ChatGPT plan connect without the official CLI
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "syndai-codex-"));
|
|
28
|
+
const env = { ...process.env, CODEX_HOME: home };
|
|
29
|
+
delete env.OPENAI_API_KEY;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
log("ChatGPT: opening a Codex login for Syndai (approve in your browser)…");
|
|
33
|
+
const login = _spawnSync("codex", ["login"], { stdio: "inherit", env });
|
|
34
|
+
if (login.status !== 0) {
|
|
35
|
+
log("ChatGPT: codex login did not complete. Skipping ChatGPT.");
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// Confirm the session landed; stdin closed so `status` can't block.
|
|
39
|
+
const status = _spawnSync("codex", ["login", "status"], {
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
env,
|
|
43
|
+
});
|
|
44
|
+
if (status.status !== 0) {
|
|
45
|
+
log("ChatGPT: login status check failed. Skipping ChatGPT.");
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const authPath = path.join(home, "auth.json");
|
|
49
|
+
if (!fs.existsSync(authPath)) {
|
|
50
|
+
log("ChatGPT: no auth.json produced. Skipping ChatGPT.");
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// Return the PARSED object: the server's upload DTO types codex_auth_json as an object,
|
|
54
|
+
// so sending the raw string would be rejected at decode (400) before the credential lands.
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(fs.readFileSync(authPath, "utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
log("ChatGPT: auth.json was not valid JSON. Skipping ChatGPT.");
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
log("ChatGPT: login captured ✓");
|
|
63
|
+
return parsed;
|
|
64
|
+
} finally {
|
|
65
|
+
try {
|
|
66
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/connect.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Orchestrates `syndai connect <code>`: claim the pairing, confirm the workspace
|
|
2
|
+
// + phrase, wait for the owner's Approve click, mint/capture whatever plans are
|
|
3
|
+
// present locally, and upload them in exactly ONE call. No token is ever printed.
|
|
4
|
+
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { post, ok, errorMessage } from "./api.js";
|
|
7
|
+
import { confirm } from "./prompt.js";
|
|
8
|
+
import { connectClaude } from "./claude.js";
|
|
9
|
+
import { connectCodex } from "./codex.js";
|
|
10
|
+
|
|
11
|
+
const BASE = "/api/v1/coding/auth/pairings";
|
|
12
|
+
const POLL_INTERVAL_MS = 3000;
|
|
13
|
+
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 min — the owner has to click Approve.
|
|
14
|
+
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
// The code-authed routes: possession of the code is the credential (spec §4.1).
|
|
18
|
+
// Poll re-uses /claim — the only code-authed status read the CLI can make; it
|
|
19
|
+
// returns the current pairing state each time.
|
|
20
|
+
// ponytail: if the backend adds a dedicated code-authed poll route, swap the path
|
|
21
|
+
// in pollState — the shape ({code, hostname} -> {state, ...}) is unchanged.
|
|
22
|
+
function claim(code) {
|
|
23
|
+
return post(`${BASE}/claim`, { code, hostname: os.hostname() });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function pollForApproval(code, deps) {
|
|
27
|
+
const log = deps.log || console.log;
|
|
28
|
+
const started = deps.now ? deps.now() : Date.now();
|
|
29
|
+
log('Waiting for the workspace owner to click "Approve"…');
|
|
30
|
+
for (;;) {
|
|
31
|
+
const { status, body } = await claim(code);
|
|
32
|
+
if (!ok(status)) return { ok: false, message: errorMessage(body) };
|
|
33
|
+
const state = body && body.state;
|
|
34
|
+
if (state === "approved" || state === "uploaded" || state === "verified") {
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
if (state === "failed") return { ok: false, message: "the pairing was rejected or expired" };
|
|
38
|
+
const elapsed = (deps.now ? deps.now() : Date.now()) - started;
|
|
39
|
+
if (elapsed >= POLL_TIMEOUT_MS) {
|
|
40
|
+
return { ok: false, message: "timed out waiting for approval (15-min code TTL — mint a new one)" };
|
|
41
|
+
}
|
|
42
|
+
await sleep(deps.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run the full connect flow. `deps` lets tests inject the network/prompt seams;
|
|
48
|
+
* defaults use the real ones. Returns a process exit code.
|
|
49
|
+
*/
|
|
50
|
+
export async function runConnect(code, deps = {}) {
|
|
51
|
+
const log = deps.log || console.log;
|
|
52
|
+
const err = deps.error || console.error;
|
|
53
|
+
const askConfirm = deps.confirm || confirm;
|
|
54
|
+
|
|
55
|
+
if (!code || typeof code !== "string") {
|
|
56
|
+
err("usage: syndai connect <code>");
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 1. Claim — learn the destination workspace + phrase before anything is minted.
|
|
61
|
+
const claimed = await (deps.claim ? deps.claim(code) : claim(code));
|
|
62
|
+
if (!ok(claimed.status)) {
|
|
63
|
+
err(`connect: ${errorMessage(claimed.body)}`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const info = claimed.body || {};
|
|
67
|
+
const name = info.workspace_name || "your workspace";
|
|
68
|
+
const email = info.owner_email_masked || "the owner";
|
|
69
|
+
const phrase = info.phrase || "(no phrase)";
|
|
70
|
+
|
|
71
|
+
// 2. Two-sided confirm (default No, no --yes flag).
|
|
72
|
+
const yes = await askConfirm(
|
|
73
|
+
`Connect to workspace "${name}" (${email})? Phrase: ${phrase} [y/N] `,
|
|
74
|
+
);
|
|
75
|
+
if (!yes) {
|
|
76
|
+
log("Aborted. Nothing was sent.");
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3. Wait for the owner's Approve click.
|
|
81
|
+
const approval = deps.pollForApproval
|
|
82
|
+
? await deps.pollForApproval(code, deps)
|
|
83
|
+
: await pollForApproval(code, deps);
|
|
84
|
+
if (!approval.ok) {
|
|
85
|
+
err(`connect: ${approval.message}`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 4. Collect whatever plans are present on this machine.
|
|
90
|
+
const claudeToken = deps.connectClaude ? await deps.connectClaude(deps) : await connectClaude(deps);
|
|
91
|
+
const codexAuthJson = deps.connectCodex ? deps.connectCodex(deps) : connectCodex(deps);
|
|
92
|
+
|
|
93
|
+
if (!claudeToken && !codexAuthJson) {
|
|
94
|
+
err("connect: no Claude or ChatGPT plan could be connected. Nothing uploaded.");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 5. Exactly one upload, carrying whatever was collected.
|
|
99
|
+
const payload = { code };
|
|
100
|
+
if (claudeToken) payload.claude_oauth_token = claudeToken;
|
|
101
|
+
if (codexAuthJson) payload.codex_auth_json = codexAuthJson;
|
|
102
|
+
|
|
103
|
+
const uploaded = deps.upload ? await deps.upload(payload) : await post(`${BASE}/upload`, payload);
|
|
104
|
+
if (!ok(uploaded.status)) {
|
|
105
|
+
err(`connect: upload failed — ${errorMessage(uploaded.body)}`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Report the SERVER's outcome, not what we sent: a token can be uploaded but fail server-side
|
|
110
|
+
// verification (a dead ChatGPT plan), and only the server knows which providers actually landed.
|
|
111
|
+
const body = uploaded.body || {};
|
|
112
|
+
const labels = { claude_max: "Claude plan", chatgpt: "ChatGPT plan" };
|
|
113
|
+
const connected = Array.isArray(body.connected) ? body.connected : [];
|
|
114
|
+
const failed = Array.isArray(body.failed) ? body.failed : [];
|
|
115
|
+
for (const p of connected) log(`Connected ✓ · ${labels[p] || p}`);
|
|
116
|
+
for (const p of failed) err(`Not connected · ${labels[p] || p} — the token could not be verified`);
|
|
117
|
+
if (connected.length === 0) {
|
|
118
|
+
err("connect: nothing was connected.");
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
package/src/proc.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Small process helpers shared by the Claude and Codex flows. Node builtins only.
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Is `name` an executable on PATH? A dependency-free `which`: scan PATH dirs for
|
|
8
|
+
* an executable file. Windows PATHEXT is honored for the .exe/.cmd case.
|
|
9
|
+
*/
|
|
10
|
+
export function onPath(name) {
|
|
11
|
+
const parts = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
|
12
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
|
|
13
|
+
for (const dir of parts) {
|
|
14
|
+
for (const ext of exts) {
|
|
15
|
+
const candidate = path.join(dir, name + ext.toLowerCase());
|
|
16
|
+
const candidateUpper = path.join(dir, name + ext);
|
|
17
|
+
for (const c of new Set([candidate, candidateUpper])) {
|
|
18
|
+
try {
|
|
19
|
+
fs.accessSync(c, fs.constants.X_OK);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
/* keep scanning */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True on the BSD family (macOS included), where script(1) takes
|
|
32
|
+
* `script -q <file> <cmd...>`; util-linux takes `script -qec "<cmd>" <file>`.
|
|
33
|
+
*/
|
|
34
|
+
export function isBsdScript() {
|
|
35
|
+
return ["darwin", "freebsd", "openbsd", "netbsd"].includes(process.platform);
|
|
36
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Terminal prompts: a default-No confirm and a no-echo paste. Both read one line
|
|
2
|
+
// from the TTY using node:readline. The paste path disables terminal echo with
|
|
3
|
+
// stty(1) so the token never reaches the scrollback — and never the clipboard,
|
|
4
|
+
// because it is typed/pasted straight into the process, not through a copy.
|
|
5
|
+
|
|
6
|
+
import readline from "node:readline";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
/** Ask a yes/no question; returns true ONLY for y/Y. Default (empty, EOF) is No. */
|
|
10
|
+
export function confirm(question) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
rl.question(question, (answer) => {
|
|
14
|
+
rl.close();
|
|
15
|
+
resolve(/^y(es)?$/i.test(String(answer).trim()));
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setEcho(on) {
|
|
21
|
+
// Best effort; on a non-TTY (or Windows without stty) this is a no-op and the
|
|
22
|
+
// paste simply echoes, which is still correct — just not hidden.
|
|
23
|
+
try {
|
|
24
|
+
spawnSync("stty", [on ? "echo" : "-echo"], { stdio: "inherit" });
|
|
25
|
+
} catch {
|
|
26
|
+
/* ignore */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read one line WITHOUT echoing it. Used for the Claude token paste fallback. */
|
|
31
|
+
export function readHidden(promptText) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
process.stdout.write(promptText);
|
|
34
|
+
setEcho(false);
|
|
35
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
36
|
+
rl.question("", (answer) => {
|
|
37
|
+
rl.close();
|
|
38
|
+
setEcho(true);
|
|
39
|
+
process.stdout.write("\n");
|
|
40
|
+
resolve(String(answer));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
package/src/tokens.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure, dependency-free string helpers for the connect flow. Kept apart from the
|
|
2
|
+
// network/PTY code (api.js, claude.js, codex.js) so the parsing logic that turns
|
|
3
|
+
// terminal output and pasted values into credentials is unit-testable without a
|
|
4
|
+
// subprocess or a socket. Node builtins only; no state, no side effects.
|
|
5
|
+
|
|
6
|
+
// Matches the escape sequences `claude setup-token` draws its full-screen UI with:
|
|
7
|
+
// CSI (`ESC [ … final`), the two-char sequences (`ESC =`, `ESC >`), and OSC strings
|
|
8
|
+
// (`ESC ] … BEL` or `ESC ] … ST`). Broad on purpose — the token we extract afterward
|
|
9
|
+
// is validated by its own strict prefix regex, so over-stripping here is harmless.
|
|
10
|
+
const ANSI = new RegExp(
|
|
11
|
+
[
|
|
12
|
+
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z0-9]*;)*[a-zA-Z0-9]*)?\\u0007)", // OSC … BEL
|
|
13
|
+
"(?:[\\u001B\\u009B][[\\]()#;?]*(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[0-9A-ORZcf-nqry=><~]))", // CSI/private
|
|
14
|
+
"[\\u001B\\u009B][=>]", // keypad mode toggles
|
|
15
|
+
].join("|"),
|
|
16
|
+
"g",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/** Strip ANSI/VT escape codes from terminal output. */
|
|
20
|
+
export function stripAnsi(text) {
|
|
21
|
+
return String(text).replace(ANSI, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A Claude plan OAuth token minted by `claude setup-token`. Prefix `sk-ant-oat`
|
|
25
|
+
// then the version + payload; the char class is exactly the plan's spec.
|
|
26
|
+
const OAT = /sk-ant-oat[0-9a-zA-Z_-]+/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pull the first `sk-ant-oat…` token out of (already ANSI-stripped) text.
|
|
30
|
+
* Returns the token string, or null when none is present.
|
|
31
|
+
*/
|
|
32
|
+
export function extractOatToken(text) {
|
|
33
|
+
const match = OAT.exec(stripAnsi(text));
|
|
34
|
+
return match ? match[0] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remove ALL internal whitespace from a single pasted token. Terminals wrap the
|
|
39
|
+
* 108-char Claude token across lines, so a copy carries a newline (often plus a
|
|
40
|
+
* leading space); the provider then reports only "Invalid auth token". Callers
|
|
41
|
+
* paste one token, so collapsing every space/newline/tab is always safe here.
|
|
42
|
+
*/
|
|
43
|
+
export function stripInternalWhitespace(value) {
|
|
44
|
+
return String(value).replace(/\s+/g, "");
|
|
45
|
+
}
|
package/cli.js
DELETED