scribeclock 0.0.0-stage → 0.1.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/README.md +148 -2
- package/bin/scribeclock.js +21 -0
- package/dist/auth/oauth.js +230 -0
- package/dist/auth/store.js +212 -0
- package/dist/auth/token.js +77 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/errors.js +48 -0
- package/dist/cli/output.js +185 -0
- package/dist/cli/parser.js +230 -0
- package/dist/cli/policy.js +49 -0
- package/dist/cli/registry.js +138 -0
- package/dist/commands/_shared.js +220 -0
- package/dist/commands/auth.js +228 -0
- package/dist/commands/automation.js +420 -0
- package/dist/commands/cf.js +270 -0
- package/dist/commands/clock.js +189 -0
- package/dist/commands/files.js +210 -0
- package/dist/commands/gitflow.js +208 -0
- package/dist/commands/index.js +35 -0
- package/dist/commands/meetings.js +254 -0
- package/dist/commands/meta.js +129 -0
- package/dist/commands/ops.js +324 -0
- package/dist/commands/projects.js +354 -0
- package/dist/commands/tasks.js +492 -0
- package/dist/commands/team.js +190 -0
- package/dist/commands/tools.js +257 -0
- package/dist/commands/vault.js +133 -0
- package/dist/commands/workflow.js +525 -0
- package/dist/config.js +102 -0
- package/dist/context.js +125 -0
- package/dist/git.js +144 -0
- package/dist/index.js +134 -0
- package/dist/mcp/catalog.js +97 -0
- package/dist/mcp/client.js +164 -0
- package/dist/mcp/schema.js +242 -0
- package/dist/render.js +117 -0
- package/dist/types.js +9 -0
- package/dist/update-check.js +28 -0
- package/dist/update.js +47 -0
- package/dist/util/format.js +176 -0
- package/package.json +35 -4
package/README.md
CHANGED
|
@@ -1,3 +1,149 @@
|
|
|
1
|
-
#
|
|
1
|
+
# scribeclock — ScribeClock from the terminal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tasks, time clock, projects and PRDs, meetings, files, the project vault and Cloudflare, from a shell,
|
|
4
|
+
a script or CI. No browser or AI client needed.
|
|
5
|
+
|
|
6
|
+
The CLI is a thin client over the [ScribeClock MCP server](https://scribeclock.com/help/mcp-server):
|
|
7
|
+
every tool the server exposes is a command here, generated from the server's live `tools/list`, so a
|
|
8
|
+
new server tool appears in the CLI without a release. Friendlier aliases and workflow commands are built
|
|
9
|
+
on top of the same tools, so the CLI, AI agents and the web app always behave the same way.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g scribeclock # or: npx scribeclock <command>
|
|
13
|
+
scribeclock login # browser sign-in (OAuth 2.1 + PKCE); tokens go to your keychain
|
|
14
|
+
scribeclock doctor # login, scopes, server, git hooks, version
|
|
15
|
+
scribeclock today # timer, in-progress, due today, next meeting
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Node 20 or newer. No runtime dependencies.
|
|
19
|
+
|
|
20
|
+
## Login and tokens
|
|
21
|
+
|
|
22
|
+
| Way | When |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `scribeclock login` | Interactive. Opens scribeclock.com, you pick permissions and organizations, the CLI receives the tokens on `127.0.0.1`. Access tokens last 8 h and refresh automatically for 90 days (the rotated refresh token is saved every time). |
|
|
25
|
+
| `scribeclock login --scopes tasks:read,tasks:write` | Ask for less. Default is everything except Cloudflare; `--all` includes it. |
|
|
26
|
+
| `scribeclock login --token scmcp_…` / `--token -` | A personal access token from Settings → AI agents (stdin with `-`). |
|
|
27
|
+
| `SCRIBECLOCK_TOKEN=scmcp_…` | Headless and CI. Always wins over a stored login. |
|
|
28
|
+
| `scribeclock login --paste` | Headless machine with a browser elsewhere: paste the redirect URL back. |
|
|
29
|
+
| `scribeclock logout` | Revokes the tokens and forgets the login. |
|
|
30
|
+
|
|
31
|
+
Credentials live in the macOS Keychain or libsecret (`secret-tool`) on Linux; without a usable
|
|
32
|
+
keychain they go to `~/.config/scribeclock/credentials.json` with mode `0600` and a one-time warning.
|
|
33
|
+
Tokens are never printed; anything that looks like one is scrubbed from output.
|
|
34
|
+
|
|
35
|
+
`SCRIBECLOCK_URL` (or `--url`) points the CLI at another server (staging). Logins are kept per server.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
Run `scribeclock --help` for the full list and `scribeclock <command> --help` for flags and examples.
|
|
40
|
+
|
|
41
|
+
| Area | Commands |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| Daily | `today` · `next` · `start KEY` · `progress KEY "…" [--done\|--blocked]` · `done KEY` · `inbox` · `standup` |
|
|
44
|
+
| Time | `clock in [--task #12\|--project P]` · `clock out [--at 17:30] [--handoff]` · `clock status` · `prompt` · `timesheet [--week\|--month\|--since …] [--csv\|--json]` |
|
|
45
|
+
| Tasks | `tasks list\|show\|create\|update\|claim\|release\|complete\|comment\|comments\|assign\|attach\|delete\|restore\|match\|repo\|workload\|stale` · `members` |
|
|
46
|
+
| Projects | `projects list\|show\|create\|update` · `prd list\|show\|create\|update\|diff\|to-tasks` |
|
|
47
|
+
| Meetings & docs | `meetings list\|show\|transcript [--summarize]\|actions [--create-tasks]` · `docs new --from file.md` |
|
|
48
|
+
| Files | `files ls\|get\|put\|rm` |
|
|
49
|
+
| Vault & env | `vault ls\|get\|set\|credential\|rm` · `env pull\|push` · `run -- <command>` |
|
|
50
|
+
| Cloudflare | `cf accounts\|zones\|dns\|dns set\|dns rm\|dns diff\|pages\|deployments\|deploy-status\|workers\|purge\|request` |
|
|
51
|
+
| Team | `team` · `overlap [files…]` (exit 4 on overlap) · `team report` · `handoff [record]` |
|
|
52
|
+
| Git | `branch KEY [--create]` · `commit -m "…"` · `pr KEY` · `changelog --since <tag>` |
|
|
53
|
+
| Ops & agents | `doctor` · `watch tasks [--on-change cmd]` · `serve --stdio` · `mcp-config <client> [--install]` · `ask "…"` |
|
|
54
|
+
| Raw tools | `tools` · `call <tool> [--arg v \| --args '{json}']` · `<tool_name> …` · `refresh` |
|
|
55
|
+
|
|
56
|
+
Any server tool works directly, with arguments typed from its schema:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
scribeclock list_tasks --assignee me --status open --json | jq '.tasks[].title'
|
|
60
|
+
scribeclock get_task '#731'
|
|
61
|
+
scribeclock create_task --args '{"title":"Fix login","priority":"high"}'
|
|
62
|
+
scribeclock create_document --title Spec --content @spec.md # @file reads a file, @- stdin
|
|
63
|
+
scribeclock cloudflare_dns_upsert --help # every tool has help
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Common workflows
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Morning
|
|
70
|
+
scribeclock today
|
|
71
|
+
scribeclock next
|
|
72
|
+
scribeclock start #731 # claim + clock in against the task + context
|
|
73
|
+
|
|
74
|
+
# While working (inside the repo: the task comes from the branch or last commit)
|
|
75
|
+
scribeclock progress auto "DNS pointed at Pages; waiting for SSL"
|
|
76
|
+
scribeclock commit -m "Point apex at Pages" # appends (#731) and posts the commit as progress
|
|
77
|
+
scribeclock overlap src/dns.ts # exit 4 if a teammate is on the same file
|
|
78
|
+
|
|
79
|
+
# Finishing
|
|
80
|
+
scribeclock done #731 -m "Site live with SSL; checkout tested." # runs .scribeclock.json checks first
|
|
81
|
+
scribeclock clock out --handoff -m "Kisa Print deploy" --next "smoke test checkout"
|
|
82
|
+
scribeclock standup | pbcopy
|
|
83
|
+
|
|
84
|
+
# Billing
|
|
85
|
+
scribeclock timesheet --last-month --person all --csv > october.csv
|
|
86
|
+
|
|
87
|
+
# Secrets (values masked; --reveal to print; vault reads are audited)
|
|
88
|
+
scribeclock env pull --project 'Kisa Print' -t development
|
|
89
|
+
scribeclock run -p 'Kisa Print' -- npm run dev
|
|
90
|
+
scribeclock env push -p 'Kisa Print' -f .env.production -t production --dry-run
|
|
91
|
+
|
|
92
|
+
# Cloudflare (live changes ask first; --dry-run previews)
|
|
93
|
+
scribeclock cf dns diff scribeclock.com dns.json
|
|
94
|
+
scribeclock cf deploy-status scribeclock
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Put a `.scribeclock.json` at the repository root to save typing:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{ "project": "Kisa Print", "checks": ["npm test", "npm run lint"], "branch_prefix": "feat" }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Safety
|
|
104
|
+
|
|
105
|
+
- Destructive and sensitive tools (`delete_*`, vault reads and writes, Cloudflare DNS, purge, any
|
|
106
|
+
non-GET `cloudflare_request`) ask for confirmation. Without a terminal they refuse unless `--yes`.
|
|
107
|
+
- `--dry-run` prints the exact call and sends nothing (for `delete_organization_file` the server
|
|
108
|
+
describes the deletion instead).
|
|
109
|
+
- Vault values are masked unless `--reveal`; variables stored as secrets are write-only through MCP
|
|
110
|
+
and only their names come back.
|
|
111
|
+
- Branch creation is opt-in (`branch KEY --create`) and refused in repositories whose hooks enforce
|
|
112
|
+
"main only" unless `git config scribeclock.allowBranches true`.
|
|
113
|
+
|
|
114
|
+
## Output, exit codes, scripting
|
|
115
|
+
|
|
116
|
+
- Human-readable by default; colors only on a TTY, `NO_COLOR` respected.
|
|
117
|
+
- `--json` on every command prints the server's result unchanged (stable shapes), or a documented
|
|
118
|
+
object for composite commands (`today`, `standup`, `timesheet`, `doctor`, `inbox`, `next`).
|
|
119
|
+
- `watch tasks --json` streams NDJSON. Errors in `--json` mode are
|
|
120
|
+
`{ ok:false, error, message, hint, exit_code }`.
|
|
121
|
+
- Exit codes: `0` ok · `1` error · `2` usage · `3` not authenticated · `4` conflict / overlap found ·
|
|
122
|
+
`5` missing scope (the message names the scope and the `login --scopes` command to add it).
|
|
123
|
+
- `--org <id>` (or `.scribeclock.json` → `org`) picks the organization when you belong to several.
|
|
124
|
+
- Shell completions: `scribeclock completions bash|zsh|fish`.
|
|
125
|
+
- Prompt segment: `PS1='$(scribeclock prompt) \w \$ '` shows `⏱ 2h13m #731` while clocked in.
|
|
126
|
+
|
|
127
|
+
## Agents
|
|
128
|
+
|
|
129
|
+
`scribeclock serve --stdio` runs the CLI as a local MCP server that proxies to ScribeClock with your
|
|
130
|
+
login, for clients that prefer local servers. `scribeclock mcp-config claude-code|cursor|vscode|windsurf|codex|gemini [--local] [--install]`
|
|
131
|
+
prints or writes the matching client configuration. `scribeclock ask "what's overdue for me?"` maps a
|
|
132
|
+
plain request to one tool call (built-in rules, no AI service), shows it, and asks before anything that
|
|
133
|
+
writes.
|
|
134
|
+
|
|
135
|
+
## CI
|
|
136
|
+
|
|
137
|
+
`examples/github-actions/` has a nightly workload report and a stale-task sweep. Both use
|
|
138
|
+
`SCRIBECLOCK_TOKEN` and `npx scribeclock@latest … --json | jq`.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
cd packages/cli
|
|
144
|
+
npm run typecheck && npm test # unit tests, incl. parity against supabase/functions/mcp/tools.ts
|
|
145
|
+
npm run build && node bin/scribeclock.js --help
|
|
146
|
+
SCRIBECLOCK_INTEGRATION=1 npm run test:integration # live endpoint; add SCRIBECLOCK_TOKEN for authenticated checks
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The parity test imports the server's tool catalog, so a new MCP tool without CLI reachability fails CI.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const entry = join(here, "..", "dist", "index.js");
|
|
8
|
+
if (!existsSync(entry)) {
|
|
9
|
+
process.stderr.write("scribeclock: dist/ is missing. Run `npm run build` in packages/cli (or install the published package).\n");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
// `scribeclock … | head` closes the pipe early: that is not an error.
|
|
13
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
14
|
+
stream.on("error", (err) => {
|
|
15
|
+
if (err && err.code === "EPIPE") process.exit(process.exitCode ?? 0);
|
|
16
|
+
throw err;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { main } = await import(entry);
|
|
21
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { CliError } from "../cli/errors.js";
|
|
5
|
+
export const ALL_SCOPES = [
|
|
6
|
+
"tasks:read",
|
|
7
|
+
"tasks:write",
|
|
8
|
+
"projects:read",
|
|
9
|
+
"projects:write",
|
|
10
|
+
"files:read",
|
|
11
|
+
"files:write",
|
|
12
|
+
"secrets:read",
|
|
13
|
+
"secrets:write",
|
|
14
|
+
"meetings:read",
|
|
15
|
+
"cloudflare:read",
|
|
16
|
+
"cloudflare:write",
|
|
17
|
+
];
|
|
18
|
+
/** What `login` asks for by default: everything except Cloudflare, which is opt-in (--scopes / --all). */
|
|
19
|
+
export const DEFAULT_SCOPES = ALL_SCOPES.filter((s) => !s.startsWith("cloudflare:"));
|
|
20
|
+
export function base64url(bytes) {
|
|
21
|
+
return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
export function pkcePair() {
|
|
24
|
+
const verifier = base64url(randomBytes(48)); // 64 chars, within RFC 7636's 43–128
|
|
25
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
26
|
+
return { verifier, challenge };
|
|
27
|
+
}
|
|
28
|
+
export function parseWwwAuthenticate(header) {
|
|
29
|
+
if (!header)
|
|
30
|
+
return {};
|
|
31
|
+
const m = /resource_metadata="([^"]+)"/.exec(header);
|
|
32
|
+
return m ? { resource_metadata: m[1] } : {};
|
|
33
|
+
}
|
|
34
|
+
async function getJson(fetchImpl, url) {
|
|
35
|
+
const res = await fetchImpl(url, { headers: { accept: "application/json" } });
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new CliError(`${url} answered ${res.status}.`, { id: "discovery_failed" });
|
|
38
|
+
return (await res.json());
|
|
39
|
+
}
|
|
40
|
+
/** RFC 9728 + RFC 8414 discovery, starting from the 401 challenge of the MCP endpoint. */
|
|
41
|
+
export async function discover(endpoint, fetchImpl = globalThis.fetch) {
|
|
42
|
+
let metadataUrl;
|
|
43
|
+
let resource = endpoint;
|
|
44
|
+
try {
|
|
45
|
+
const probe = await fetchImpl(endpoint, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
48
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
|
|
49
|
+
});
|
|
50
|
+
metadataUrl = parseWwwAuthenticate(probe.headers.get("www-authenticate")).resource_metadata;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
throw new CliError(`Could not reach ${endpoint} (${err instanceof Error ? err.message : String(err)}).`, { id: "network", hint: "Check your network, or SCRIBECLOCK_URL / --url." });
|
|
54
|
+
}
|
|
55
|
+
if (!metadataUrl) {
|
|
56
|
+
const u = new URL(endpoint);
|
|
57
|
+
metadataUrl = `${u.origin}/.well-known/oauth-protected-resource${u.pathname === "/" ? "" : u.pathname}`;
|
|
58
|
+
}
|
|
59
|
+
const prm = await getJson(fetchImpl, metadataUrl);
|
|
60
|
+
if (typeof prm.resource === "string")
|
|
61
|
+
resource = prm.resource;
|
|
62
|
+
const issuer = String(prm.authorization_servers?.[0] ?? new URL(endpoint).origin).replace(/\/$/, "");
|
|
63
|
+
const asm = await getJson(fetchImpl, `${issuer}/.well-known/oauth-authorization-server`);
|
|
64
|
+
const need = (k) => {
|
|
65
|
+
const v = asm[k];
|
|
66
|
+
if (typeof v !== "string")
|
|
67
|
+
throw new CliError(`The authorization server metadata has no ${k}.`, { id: "discovery_failed" });
|
|
68
|
+
return v;
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
endpoint,
|
|
72
|
+
resource,
|
|
73
|
+
issuer: String(asm.issuer ?? issuer),
|
|
74
|
+
authorization_endpoint: need("authorization_endpoint"),
|
|
75
|
+
token_endpoint: need("token_endpoint"),
|
|
76
|
+
registration_endpoint: typeof asm.registration_endpoint === "string" ? asm.registration_endpoint : undefined,
|
|
77
|
+
revocation_endpoint: typeof asm.revocation_endpoint === "string" ? asm.revocation_endpoint : undefined,
|
|
78
|
+
scopes_supported: Array.isArray(asm.scopes_supported) ? asm.scopes_supported.map(String) : undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** RFC 7591 dynamic registration of a public (PKCE-only) client with loopback redirects. */
|
|
82
|
+
export async function registerClient(meta, fetchImpl, opts) {
|
|
83
|
+
if (!meta.registration_endpoint)
|
|
84
|
+
throw new CliError("The server does not offer dynamic client registration.", { id: "registration_unsupported" });
|
|
85
|
+
const res = await fetchImpl(meta.registration_endpoint, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
client_name: opts.clientName,
|
|
90
|
+
redirect_uris: ["http://127.0.0.1/callback", "http://localhost/callback"],
|
|
91
|
+
token_endpoint_auth_method: "none",
|
|
92
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
93
|
+
response_types: ["code"],
|
|
94
|
+
scope: opts.scopes.join(" "),
|
|
95
|
+
client_uri: "https://scribeclock.com",
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
const json = (await res.json().catch(() => ({})));
|
|
99
|
+
if (res.status !== 201 && res.status !== 200) {
|
|
100
|
+
throw new CliError(`Client registration failed (${res.status}): ${String(json.error_description ?? json.error ?? "")}`.trim(), { id: "registration_failed" });
|
|
101
|
+
}
|
|
102
|
+
if (typeof json.client_id !== "string")
|
|
103
|
+
throw new CliError("Client registration returned no client_id.", { id: "registration_failed" });
|
|
104
|
+
return json.client_id;
|
|
105
|
+
}
|
|
106
|
+
async function tokenRequest(fetchImpl, tokenEndpoint, params) {
|
|
107
|
+
const body = new URLSearchParams();
|
|
108
|
+
for (const [k, v] of Object.entries(params))
|
|
109
|
+
if (v !== undefined)
|
|
110
|
+
body.set(k, v);
|
|
111
|
+
const res = await fetchImpl(tokenEndpoint, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
114
|
+
body,
|
|
115
|
+
});
|
|
116
|
+
const json = (await res.json().catch(() => ({})));
|
|
117
|
+
return { status: res.status, json };
|
|
118
|
+
}
|
|
119
|
+
export async function exchangeCode(fetchImpl, meta, p) {
|
|
120
|
+
const { status, json } = await tokenRequest(fetchImpl, meta.token_endpoint, {
|
|
121
|
+
grant_type: "authorization_code",
|
|
122
|
+
code: p.code,
|
|
123
|
+
code_verifier: p.verifier,
|
|
124
|
+
client_id: p.client_id,
|
|
125
|
+
redirect_uri: p.redirect_uri,
|
|
126
|
+
resource: meta.resource,
|
|
127
|
+
});
|
|
128
|
+
if (status !== 200 || typeof json.access_token !== "string") {
|
|
129
|
+
throw new CliError(`Token exchange failed: ${String(json.error_description ?? json.error ?? status)}`, { id: "token_exchange_failed" });
|
|
130
|
+
}
|
|
131
|
+
return json;
|
|
132
|
+
}
|
|
133
|
+
/** Rotating refresh: the response carries a NEW refresh token that must replace the old one. */
|
|
134
|
+
export async function refreshTokens(fetchImpl, tokenEndpoint, refreshToken, clientId) {
|
|
135
|
+
const { status, json } = await tokenRequest(fetchImpl, tokenEndpoint, { grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId });
|
|
136
|
+
if (status === 200 && typeof json.access_token === "string")
|
|
137
|
+
return { ok: true, tokens: json };
|
|
138
|
+
return { ok: false, error: String(json.error ?? `http_${status}`), description: typeof json.error_description === "string" ? json.error_description : undefined };
|
|
139
|
+
}
|
|
140
|
+
export async function revokeToken(fetchImpl, revocationEndpoint, token, clientId, hint) {
|
|
141
|
+
const { status } = await tokenRequest(fetchImpl, revocationEndpoint, { token, token_type_hint: hint, client_id: clientId });
|
|
142
|
+
return status === 200;
|
|
143
|
+
}
|
|
144
|
+
export function buildAuthorizeUrl(meta, p) {
|
|
145
|
+
const u = new URL(meta.authorization_endpoint);
|
|
146
|
+
u.searchParams.set("response_type", "code");
|
|
147
|
+
u.searchParams.set("client_id", p.client_id);
|
|
148
|
+
u.searchParams.set("redirect_uri", p.redirect_uri);
|
|
149
|
+
u.searchParams.set("state", p.state);
|
|
150
|
+
u.searchParams.set("scope", p.scopes.join(" "));
|
|
151
|
+
u.searchParams.set("code_challenge", p.challenge);
|
|
152
|
+
u.searchParams.set("code_challenge_method", "S256");
|
|
153
|
+
u.searchParams.set("resource", meta.resource);
|
|
154
|
+
return u.toString();
|
|
155
|
+
}
|
|
156
|
+
export function openBrowser(url, platform = process.platform) {
|
|
157
|
+
try {
|
|
158
|
+
const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
159
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
160
|
+
child.on("error", () => undefined);
|
|
161
|
+
child.unref();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>ScribeClock CLI</title>
|
|
169
|
+
<body style="font-family:system-ui;margin:4rem auto;max-width:28rem;text-align:center;color:#1b1b1f">
|
|
170
|
+
<h1 style="font-size:1.4rem">You're signed in to the ScribeClock CLI</h1><p>You can close this window and go back to your terminal.</p></body>`;
|
|
171
|
+
const FAIL_HTML = (msg) => `<!doctype html><meta charset="utf-8"><title>ScribeClock CLI</title>
|
|
172
|
+
<body style="font-family:system-ui;margin:4rem auto;max-width:28rem;text-align:center;color:#1b1b1f"><h1 style="font-size:1.4rem">Sign-in did not finish</h1><p>${msg}</p></body>`;
|
|
173
|
+
/**
|
|
174
|
+
* Listens once on 127.0.0.1:<random port>/callback and resolves with the code. The state must match
|
|
175
|
+
* (CSRF) and the server answers a friendly page either way.
|
|
176
|
+
*/
|
|
177
|
+
export function waitForCallback(opts) {
|
|
178
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
179
|
+
let settle = null;
|
|
180
|
+
let fail = null;
|
|
181
|
+
const codePromise = new Promise((res, rej) => {
|
|
182
|
+
settle = res;
|
|
183
|
+
fail = rej;
|
|
184
|
+
});
|
|
185
|
+
const server = createServer((req, res) => {
|
|
186
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
187
|
+
if (url.pathname !== "/callback") {
|
|
188
|
+
res.writeHead(404).end();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const state = url.searchParams.get("state");
|
|
192
|
+
const code = url.searchParams.get("code");
|
|
193
|
+
const error = url.searchParams.get("error");
|
|
194
|
+
if (state !== opts.state) {
|
|
195
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(FAIL_HTML("The state did not match. Start the login again."));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (error || !code) {
|
|
199
|
+
const desc = url.searchParams.get("error_description") ?? error ?? "No authorization code was returned.";
|
|
200
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(FAIL_HTML(escapeHtml(desc)));
|
|
201
|
+
fail?.(new CliError(`Authorization was not granted: ${desc}`, { id: "authorization_denied" }));
|
|
202
|
+
setTimeout(() => server.close(), 50);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(SUCCESS_HTML);
|
|
206
|
+
settle?.(code);
|
|
207
|
+
setTimeout(() => server.close(), 50);
|
|
208
|
+
});
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
fail?.(new CliError("Timed out waiting for the browser sign-in (5 minutes).", { id: "login_timeout", hint: "Run `scribeclock login` again, or use `scribeclock login --token` with a personal access token." }));
|
|
211
|
+
server.close();
|
|
212
|
+
}, opts.timeoutMs ?? 5 * 60 * 1000);
|
|
213
|
+
timer.unref();
|
|
214
|
+
server.on("error", (err) => rejectStart(err));
|
|
215
|
+
server.listen(opts.port ?? 0, "127.0.0.1", () => {
|
|
216
|
+
const { port } = server.address();
|
|
217
|
+
resolveStart({ redirectUri: `http://127.0.0.1:${port}/callback`, code: codePromise.finally(() => clearTimeout(timer)) });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function escapeHtml(s) {
|
|
222
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
223
|
+
}
|
|
224
|
+
export function randomState() {
|
|
225
|
+
return base64url(randomBytes(24));
|
|
226
|
+
}
|
|
227
|
+
export function expiresAt(expiresIn, now = new Date()) {
|
|
228
|
+
const seconds = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 8 * 3600;
|
|
229
|
+
return new Date(now.getTime() + seconds * 1000).toISOString();
|
|
230
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { writeJsonPrivate } from "../config.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const ACCOUNT = "scribeclock";
|
|
8
|
+
const service = (profile) => `scribeclock-cli:${profile}`;
|
|
9
|
+
const defaultExec = async (file, args) => {
|
|
10
|
+
const { stdout, stderr } = await execFileAsync(file, args, { encoding: "utf8", maxBuffer: 1024 * 1024 });
|
|
11
|
+
return { stdout: String(stdout), stderr: String(stderr) };
|
|
12
|
+
};
|
|
13
|
+
/** macOS Keychain via `security`. The secret travels as a process argument, as every CLI using `security` does. */
|
|
14
|
+
export class MacKeychainStore {
|
|
15
|
+
exec;
|
|
16
|
+
backend = "keychain";
|
|
17
|
+
constructor(exec = defaultExec) {
|
|
18
|
+
this.exec = exec;
|
|
19
|
+
}
|
|
20
|
+
async load(profile) {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await this.exec("security", ["find-generic-password", "-a", ACCOUNT, "-s", service(profile), "-w"]);
|
|
23
|
+
const raw = stdout.trim();
|
|
24
|
+
if (!raw)
|
|
25
|
+
return null;
|
|
26
|
+
// `security -w` prints hex for values with non-ASCII bytes; ours is plain JSON.
|
|
27
|
+
return JSON.parse(raw.startsWith("{") ? raw : Buffer.from(raw, "hex").toString("utf8"));
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (isNotFound(err))
|
|
31
|
+
return null;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async save(profile, creds) {
|
|
36
|
+
await this.exec("security", ["add-generic-password", "-U", "-a", ACCOUNT, "-s", service(profile), "-l", `ScribeClock CLI (${profile})`, "-w", JSON.stringify(creds)]);
|
|
37
|
+
}
|
|
38
|
+
async clear(profile) {
|
|
39
|
+
try {
|
|
40
|
+
await this.exec("security", ["delete-generic-password", "-a", ACCOUNT, "-s", service(profile)]);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (!isNotFound(err))
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Linux: libsecret's `secret-tool` (GNOME Keyring, KWallet via the Secret Service API). */
|
|
49
|
+
export class SecretToolStore {
|
|
50
|
+
exec;
|
|
51
|
+
backend = "keychain";
|
|
52
|
+
constructor(exec = defaultExecWithInput) {
|
|
53
|
+
this.exec = exec;
|
|
54
|
+
}
|
|
55
|
+
async load(profile) {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await this.exec("secret-tool", ["lookup", "service", service(profile), "account", ACCOUNT]);
|
|
58
|
+
return stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (isNotFound(err))
|
|
62
|
+
return null;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async save(profile, creds) {
|
|
67
|
+
await this.exec("secret-tool", ["store", "--label", `ScribeClock CLI (${profile})`, "service", service(profile), "account", ACCOUNT], JSON.stringify(creds));
|
|
68
|
+
}
|
|
69
|
+
async clear(profile) {
|
|
70
|
+
try {
|
|
71
|
+
await this.exec("secret-tool", ["clear", "service", service(profile), "account", ACCOUNT]);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (!isNotFound(err))
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const defaultExecWithInput = (file, args, input) => new Promise((resolve, reject) => {
|
|
80
|
+
const child = execFile(file, args, { encoding: "utf8" }, (err, stdout, stderr) => {
|
|
81
|
+
if (err)
|
|
82
|
+
reject(Object.assign(err, { stdout, stderr }));
|
|
83
|
+
else
|
|
84
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
85
|
+
});
|
|
86
|
+
if (input !== undefined)
|
|
87
|
+
child.stdin?.end(input);
|
|
88
|
+
});
|
|
89
|
+
function isNotFound(err) {
|
|
90
|
+
const e = err;
|
|
91
|
+
const text = `${e.stderr ?? ""} ${e.message ?? ""}`;
|
|
92
|
+
return e.code === 44 || /could not be found|not found|No such|SecKeychainSearchCopyNext/i.test(text) || (e.code === 1 && /secret-tool/.test(text) && !e.stderr);
|
|
93
|
+
}
|
|
94
|
+
/** `credentials.json` with mode 0600 — the fallback when no keychain is usable. */
|
|
95
|
+
export class FileStore {
|
|
96
|
+
backend = "file";
|
|
97
|
+
path;
|
|
98
|
+
constructor(dir) {
|
|
99
|
+
this.path = join(dir, "credentials.json");
|
|
100
|
+
}
|
|
101
|
+
readAll() {
|
|
102
|
+
if (!existsSync(this.path))
|
|
103
|
+
return {};
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
106
|
+
return parsed.profiles ?? {};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async load(profile) {
|
|
113
|
+
return this.readAll()[profile] ?? null;
|
|
114
|
+
}
|
|
115
|
+
async save(profile, creds) {
|
|
116
|
+
const all = this.readAll();
|
|
117
|
+
all[profile] = creds;
|
|
118
|
+
writeJsonPrivate(this.path, { version: 1, profiles: all });
|
|
119
|
+
}
|
|
120
|
+
async clear(profile) {
|
|
121
|
+
const all = this.readAll();
|
|
122
|
+
if (!(profile in all))
|
|
123
|
+
return;
|
|
124
|
+
delete all[profile];
|
|
125
|
+
if (Object.keys(all).length === 0) {
|
|
126
|
+
try {
|
|
127
|
+
unlinkSync(this.path);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* already gone */
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
writeJsonPrivate(this.path, { version: 1, profiles: all });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Keychain when the platform has one; otherwise (or on SCRIBECLOCK_CREDENTIAL_STORE=file) the 0600
|
|
139
|
+
* file. If the keychain fails at runtime the caller falls back to the file and warns once.
|
|
140
|
+
*/
|
|
141
|
+
export function createCredentialStore(opts) {
|
|
142
|
+
const env = opts.env ?? process.env;
|
|
143
|
+
const platform = opts.platform ?? process.platform;
|
|
144
|
+
const fallback = new FileStore(opts.configDir);
|
|
145
|
+
const forced = env.SCRIBECLOCK_CREDENTIAL_STORE;
|
|
146
|
+
if (forced === "file")
|
|
147
|
+
return { primary: fallback, fallback };
|
|
148
|
+
if (platform === "darwin")
|
|
149
|
+
return { primary: new MacKeychainStore(), fallback };
|
|
150
|
+
if (platform === "linux")
|
|
151
|
+
return { primary: new SecretToolStore(), fallback };
|
|
152
|
+
return { primary: fallback, fallback };
|
|
153
|
+
}
|
|
154
|
+
/** Tries the keychain, falls back to the file (warning once) when the keychain is unusable. */
|
|
155
|
+
export class ResilientStore {
|
|
156
|
+
primary;
|
|
157
|
+
fallbackStore;
|
|
158
|
+
warn;
|
|
159
|
+
usingFallback = false;
|
|
160
|
+
constructor(primary, fallbackStore, warn) {
|
|
161
|
+
this.primary = primary;
|
|
162
|
+
this.fallbackStore = fallbackStore;
|
|
163
|
+
this.warn = warn;
|
|
164
|
+
}
|
|
165
|
+
get backend() {
|
|
166
|
+
return this.usingFallback ? "file" : this.primary.backend;
|
|
167
|
+
}
|
|
168
|
+
degrade(err) {
|
|
169
|
+
if (this.usingFallback)
|
|
170
|
+
return;
|
|
171
|
+
this.usingFallback = true;
|
|
172
|
+
this.warn(`The system keychain is not usable (${err instanceof Error ? err.message.split("\n")[0] : String(err)}); credentials are kept in ${this.fallbackStore.path} with mode 0600 instead.`);
|
|
173
|
+
}
|
|
174
|
+
async load(profile) {
|
|
175
|
+
if (this.primary === this.fallbackStore)
|
|
176
|
+
return this.fallbackStore.load(profile);
|
|
177
|
+
if (!this.usingFallback) {
|
|
178
|
+
try {
|
|
179
|
+
const fromKeychain = await this.primary.load(profile);
|
|
180
|
+
if (fromKeychain)
|
|
181
|
+
return fromKeychain;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
this.degrade(err);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return this.fallbackStore.load(profile);
|
|
188
|
+
}
|
|
189
|
+
async save(profile, creds) {
|
|
190
|
+
if (!this.usingFallback && this.primary !== this.fallbackStore) {
|
|
191
|
+
try {
|
|
192
|
+
await this.primary.save(profile, creds);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
this.degrade(err);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
await this.fallbackStore.save(profile, creds);
|
|
200
|
+
}
|
|
201
|
+
async clear(profile) {
|
|
202
|
+
if (this.primary !== this.fallbackStore) {
|
|
203
|
+
try {
|
|
204
|
+
await this.primary.clear(profile);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* nothing to clear or keychain locked */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await this.fallbackStore.clear(profile);
|
|
211
|
+
}
|
|
212
|
+
}
|