syndai 0.1.1 → 0.4.0

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/src/connect.js DELETED
@@ -1,122 +0,0 @@
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 DELETED
@@ -1,36 +0,0 @@
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 DELETED
@@ -1,43 +0,0 @@
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 DELETED
@@ -1,45 +0,0 @@
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
- }