scribeclock 0.3.0 → 0.3.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 +2 -0
- package/dist/auth/lock.js +55 -0
- package/dist/auth/store.js +21 -5
- package/dist/auth/token.js +67 -29
- package/dist/commands/ingest.js +21 -2
- package/dist/commands/team.js +33 -1
- package/dist/mcp/client.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Requires Node 20 or newer. No runtime dependencies.
|
|
|
30
30
|
|
|
31
31
|
Credentials live in the macOS Keychain or libsecret (`secret-tool`) on Linux; without a usable
|
|
32
32
|
keychain they go to `~/.config/scribeclock/credentials.json` with mode `0600` and a one-time warning.
|
|
33
|
+
Concurrent commands share session refresh safely. Temporary refresh failures preserve the saved login; retry the command when the service is available.
|
|
33
34
|
Tokens are never printed; anything that looks like one is scrubbed from output.
|
|
34
35
|
|
|
35
36
|
`SCRIBECLOCK_URL` (or `--url`) points the CLI at another server (staging). Logins are kept per server.
|
|
@@ -93,6 +94,7 @@ scribeclock start #731 # claim + clock in against the task +
|
|
|
93
94
|
scribeclock progress auto "DNS pointed at Pages; waiting for SSL"
|
|
94
95
|
scribeclock commit -m "Point apex at Pages" # appends (#731) and posts the commit as progress
|
|
95
96
|
scribeclock overlap src/dns.ts # exit 4 if a teammate is on the same file
|
|
97
|
+
scribeclock delivery # client project: push here, or is this the client's repo? (exit 4 = do not push)
|
|
96
98
|
|
|
97
99
|
# Finishing
|
|
98
100
|
scribeclock done #731 -m "Site live with SSL; checkout tested." # runs .scribeclock.json checks first
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CliError } from "../cli/errors.js";
|
|
5
|
+
/** Keep a rotating refresh token exclusive until its replacement is safely persisted. */
|
|
6
|
+
export async function withRefreshLock(dir, profile, run) {
|
|
7
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
8
|
+
const path = join(dir, `refresh-${createHash("sha256").update(profile).digest("hex")}.lock`);
|
|
9
|
+
const started = Date.now();
|
|
10
|
+
for (;;) {
|
|
11
|
+
try {
|
|
12
|
+
const fd = openSync(path, "wx", 0o600);
|
|
13
|
+
try {
|
|
14
|
+
writeFileSync(fd, String(process.pid));
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
closeSync(fd);
|
|
18
|
+
}
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code !== "EEXIST")
|
|
23
|
+
throw err;
|
|
24
|
+
try {
|
|
25
|
+
const pid = Number(readFileSync(path, "utf8"));
|
|
26
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err.code === "ESRCH")
|
|
32
|
+
unlinkSync(path);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (Date.now() - statSync(path).mtimeMs > 30_000) {
|
|
36
|
+
unlinkSync(path);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err.code !== "ENOENT")
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
if (Date.now() - started > 30_000) {
|
|
44
|
+
throw new CliError("Another ScribeClock command is refreshing your session. Try again shortly.", { id: "refresh_busy" });
|
|
45
|
+
}
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return await run();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
unlinkSync(path);
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/auth/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, renameSync, unlinkSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { writeJsonPrivate } from "../config.js";
|
|
@@ -109,13 +110,24 @@ export class FileStore {
|
|
|
109
110
|
return {};
|
|
110
111
|
}
|
|
111
112
|
}
|
|
113
|
+
writeAll(profiles) {
|
|
114
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
115
|
+
try {
|
|
116
|
+
writeJsonPrivate(temporary, { version: 1, profiles });
|
|
117
|
+
renameSync(temporary, this.path);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
if (existsSync(temporary))
|
|
121
|
+
unlinkSync(temporary);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
112
124
|
async load(profile) {
|
|
113
125
|
return this.readAll()[profile] ?? null;
|
|
114
126
|
}
|
|
115
127
|
async save(profile, creds) {
|
|
116
128
|
const all = this.readAll();
|
|
117
129
|
all[profile] = creds;
|
|
118
|
-
|
|
130
|
+
this.writeAll(all);
|
|
119
131
|
}
|
|
120
132
|
async clear(profile) {
|
|
121
133
|
const all = this.readAll();
|
|
@@ -131,7 +143,7 @@ export class FileStore {
|
|
|
131
143
|
}
|
|
132
144
|
return;
|
|
133
145
|
}
|
|
134
|
-
|
|
146
|
+
this.writeAll(all);
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
/**
|
|
@@ -177,8 +189,11 @@ export class ResilientStore {
|
|
|
177
189
|
if (!this.usingFallback) {
|
|
178
190
|
try {
|
|
179
191
|
const fromKeychain = await this.primary.load(profile);
|
|
180
|
-
|
|
181
|
-
|
|
192
|
+
const fromFile = await this.fallbackStore.load(profile);
|
|
193
|
+
// A failed keychain write can leave the rotated pair only in the fallback file.
|
|
194
|
+
if (fromFile && (!fromKeychain || fromFile.saved_at > fromKeychain.saved_at))
|
|
195
|
+
return fromFile;
|
|
196
|
+
return fromKeychain ?? fromFile;
|
|
182
197
|
}
|
|
183
198
|
catch (err) {
|
|
184
199
|
this.degrade(err);
|
|
@@ -190,6 +205,7 @@ export class ResilientStore {
|
|
|
190
205
|
if (!this.usingFallback && this.primary !== this.fallbackStore) {
|
|
191
206
|
try {
|
|
192
207
|
await this.primary.save(profile, creds);
|
|
208
|
+
await this.fallbackStore.clear(profile);
|
|
193
209
|
return;
|
|
194
210
|
}
|
|
195
211
|
catch (err) {
|
package/dist/auth/token.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { configDir } from "../config.js";
|
|
2
|
+
import { withRefreshLock } from "./lock.js";
|
|
3
|
+
import { CliError, authError } from "../cli/errors.js";
|
|
2
4
|
import { refreshTokens, expiresAt } from "./oauth.js";
|
|
3
5
|
/** Access tokens expire after 8 h; refresh this long before that so a call never lands on a dead token. */
|
|
4
6
|
const REFRESH_SKEW_MS = 60 * 1000;
|
|
5
7
|
export class TokenProvider {
|
|
6
8
|
opts;
|
|
9
|
+
refreshing;
|
|
7
10
|
cached;
|
|
8
11
|
constructor(opts) {
|
|
9
12
|
this.opts = opts;
|
|
@@ -35,40 +38,75 @@ export class TokenProvider {
|
|
|
35
38
|
if (!creds)
|
|
36
39
|
return null;
|
|
37
40
|
if (creds.kind === "oauth" && creds.refresh_token && this.isExpiring(creds)) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
try {
|
|
42
|
+
const refreshed = await this.refresh(creds.access_token);
|
|
43
|
+
if (refreshed)
|
|
44
|
+
return refreshed;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
// A transient failure (network, 5xx, busy lock) must not break a token that still works.
|
|
48
|
+
const stillValid = !creds.expires_at || new Date(creds.expires_at).getTime() > this.now().getTime();
|
|
49
|
+
if (!stillValid || err.id === "auth")
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
41
52
|
}
|
|
42
53
|
return creds.access_token;
|
|
43
54
|
}
|
|
44
|
-
/**
|
|
45
|
-
async refresh() {
|
|
55
|
+
/** Serialize rotation across providers and CLI processes, then reload the winning credentials. */
|
|
56
|
+
async refresh(rejectedToken) {
|
|
46
57
|
if (this.opts.env.SCRIBECLOCK_TOKEN)
|
|
47
58
|
return null;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
+
if (this.refreshing)
|
|
60
|
+
return this.refreshing;
|
|
61
|
+
const pending = this.refreshLocked(rejectedToken);
|
|
62
|
+
this.refreshing = pending;
|
|
63
|
+
try {
|
|
64
|
+
return await pending;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
this.refreshing = undefined;
|
|
59
68
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
}
|
|
70
|
+
async refreshLocked(rejectedToken) {
|
|
71
|
+
const observed = await this.credentials();
|
|
72
|
+
const previous = rejectedToken ?? observed?.access_token;
|
|
73
|
+
return withRefreshLock(configDir(this.opts.env), this.opts.profile, async () => {
|
|
74
|
+
const creds = await this.opts.store.load(this.opts.profile);
|
|
75
|
+
this.cached = creds;
|
|
76
|
+
if (!creds)
|
|
77
|
+
return null;
|
|
78
|
+
if (previous && creds.access_token !== previous)
|
|
79
|
+
return creds.access_token;
|
|
80
|
+
if (creds.kind !== "oauth" || !creds.refresh_token || !creds.token_endpoint)
|
|
81
|
+
return null;
|
|
82
|
+
const result = await refreshTokens(this.opts.fetch, creds.token_endpoint, creds.refresh_token, creds.client_id);
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
if (result.error === "invalid_grant" || result.error === "invalid_client") {
|
|
85
|
+
// Another process may have rotated the pair while we were waiting; use theirs if so.
|
|
86
|
+
const latest = await this.opts.store.load(this.opts.profile);
|
|
87
|
+
if (latest && latest.refresh_token !== creds.refresh_token) {
|
|
88
|
+
this.cached = latest;
|
|
89
|
+
return latest.access_token;
|
|
90
|
+
}
|
|
91
|
+
await this.opts.store.clear(this.opts.profile);
|
|
92
|
+
this.cached = null;
|
|
93
|
+
throw authError("Your ScribeClock session was revoked or expired.", "Run `scribeclock login` to sign in again.");
|
|
94
|
+
}
|
|
95
|
+
throw new CliError("Could not refresh your ScribeClock session. Try again in a moment.", { id: "refresh_failed" });
|
|
96
|
+
}
|
|
97
|
+
const next = {
|
|
98
|
+
...creds,
|
|
99
|
+
access_token: result.tokens.access_token,
|
|
100
|
+
refresh_token: result.tokens.refresh_token ?? creds.refresh_token,
|
|
101
|
+
expires_at: expiresAt(result.tokens.expires_in, this.now()),
|
|
102
|
+
scopes: result.tokens.scope ? result.tokens.scope.split(/\s+/).filter(Boolean) : creds.scopes,
|
|
103
|
+
saved_at: this.now().toISOString(),
|
|
104
|
+
};
|
|
105
|
+
await this.opts.store.save(this.opts.profile, next);
|
|
106
|
+
this.cached = next;
|
|
107
|
+
this.opts.onRefreshed?.(next);
|
|
108
|
+
return next.access_token;
|
|
109
|
+
});
|
|
72
110
|
}
|
|
73
111
|
/** Forget the in-memory copy (after login/logout). */
|
|
74
112
|
invalidate() {
|
package/dist/commands/ingest.js
CHANGED
|
@@ -121,12 +121,27 @@ sys.excepthook = lambda t, v, tb: report(v, level="fatal")`,
|
|
|
121
121
|
},
|
|
122
122
|
];
|
|
123
123
|
}
|
|
124
|
+
/** The PostHog webhook body: the event, the person, and the link back to the PostHog issue. */
|
|
125
|
+
export const POSTHOG_WEBHOOK_BODY = `{
|
|
126
|
+
"event": "{event}",
|
|
127
|
+
"person": "{person}",
|
|
128
|
+
"issue_url": "{project.url}/error_tracking/{event.properties.$exception_issue_id}"
|
|
129
|
+
}`;
|
|
124
130
|
/** Forward webhooks from an existing bug tracker (mirrors supabase/functions/_shared/ingestProviders.ts). */
|
|
125
131
|
export function providerSetups(key) {
|
|
126
132
|
const u = (p) => `${INGEST_URL}/${p}?key=${key}`;
|
|
127
133
|
return [
|
|
128
134
|
{ id: "sentry", label: "Sentry", url: u("sentry"), steps: ["Alerts → Create Alert Rule → action “Send a notification via a webhook”, or Settings → Developer Settings → Internal Integrations → Webhook URL (issue + error webhooks on).", "Paste the URL. For an internal integration, store its Client Secret on the key: scribeclock update_ingest_key --key-id <id> --webhook-secret <secret>", "Each Sentry issue becomes one ScribeClock issue; the task links back to Sentry."] },
|
|
129
|
-
{
|
|
135
|
+
{
|
|
136
|
+
id: "posthog", label: "PostHog", url: `${INGEST_URL}/posthog`, headers: { "X-ScribeClock-Key": key }, body: POSTHOG_WEBHOOK_BODY,
|
|
137
|
+
steps: [
|
|
138
|
+
"Data pipelines → Destinations → New destination → HTTP Webhook. Paste the URL (no key in it).",
|
|
139
|
+
"Under secret headers add X-ScribeClock-Key with the ingest key — keys in URLs end up in logs and screenshots.",
|
|
140
|
+
"Replace the body with the one below: it adds issue_url, so each task links back to the PostHog issue (or: scribeclock update_ingest_key --key-id <id> --posthog-project-url https://us.posthog.com/project/<id>).",
|
|
141
|
+
"Filter on the $exception event for error tracking; any other event is counted as analytics.",
|
|
142
|
+
"Optional: --webhook-secret <signing secret>, --environment-property build_channel, --posthog-api-key phx_… (error_tracking:write: resolving here resolves in PostHog), --link-only true when the app also reports its own crashes.",
|
|
143
|
+
],
|
|
144
|
+
},
|
|
130
145
|
{ id: "bugsnag", label: "Bugsnag", url: u("bugsnag"), steps: ["Project Settings → Integrations → Data forwarding → Webhook.", "Paste the URL and pick the triggers to forward (new, reopened, spikes).", "Optional shared secret: add ?secret=… to the URL and set the same value on the key."] },
|
|
131
146
|
{ id: "rollbar", label: "Rollbar", url: u("rollbar"), steps: ["Project → Settings → Notifications → Webhook.", "Paste the URL; enable new item / reactivated item / high occurrence rules.", "Optional shared secret: ?secret=… on the URL."] },
|
|
132
147
|
{ id: "crashlytics", label: "Firebase Crashlytics", url: u("crashlytics"), steps: ["Crashlytics has no outgoing webhook: add a Cloud Function on the Firebase Alerts trigger (onNewFatalIssuePublished, onRegressionAlertPublished, onNewNonfatalIssuePublished).", "POST { alertType, appId, payload } from the alert to this URL — snippet: https://scribeclock.com/help/bug-reports#forward", "Each Crashlytics issue becomes one ScribeClock issue with a link to the Firebase console."] },
|
|
@@ -137,7 +152,7 @@ export const ingestForward = cmd({
|
|
|
137
152
|
path: "ingest forward",
|
|
138
153
|
group: "Bug reports",
|
|
139
154
|
summary: "Webhook URLs and steps to forward Sentry, PostHog, Bugsnag, Rollbar, Crashlytics or any tool's reports into this project.",
|
|
140
|
-
description: "Prints the per-provider URL
|
|
155
|
+
description: "Prints the per-provider URL and where to paste it; the ingest key rides in the URL, except for PostHog, which takes it as the X-ScribeClock-Key secret header (plus the body to paste). Each issue in the other tool becomes one ScribeClock issue and task that links back. Set a webhook secret on the key (update_ingest_key --webhook-secret) to verify Sentry signatures or a shared secret the tool echoes.",
|
|
141
156
|
tool: ["list_ingest_keys"],
|
|
142
157
|
positionals: [{ name: "provider", description: "sentry, posthog, bugsnag, rollbar, crashlytics or webhook (default: all)." }],
|
|
143
158
|
flags: [{ name: "key", alias: "k", type: "string", description: `Ingest key to embed (default: ${KEY_ENV} from the environment / .env).` }],
|
|
@@ -155,7 +170,11 @@ export const ingestForward = cmd({
|
|
|
155
170
|
for (const p of providers) {
|
|
156
171
|
ctx.out.log(ctx.out.bold(`── ${p.label}`));
|
|
157
172
|
ctx.out.log(` ${p.url}`);
|
|
173
|
+
for (const [h, v] of Object.entries(p.headers ?? {}))
|
|
174
|
+
ctx.out.log(` header ${h}: ${v}`);
|
|
158
175
|
p.steps.forEach((s, i) => ctx.out.log(` ${i + 1}. ${s}`));
|
|
176
|
+
if (p.body)
|
|
177
|
+
ctx.out.log(` body:\n${p.body.replace(/^/gm, " ")}`);
|
|
159
178
|
ctx.out.log("");
|
|
160
179
|
}
|
|
161
180
|
if (key.startsWith("sc_ing_<"))
|
package/dist/commands/team.js
CHANGED
|
@@ -187,4 +187,36 @@ export const handoffRecord = cmd({
|
|
|
187
187
|
});
|
|
188
188
|
},
|
|
189
189
|
});
|
|
190
|
-
export const
|
|
190
|
+
export const deliveryCommand = cmd({
|
|
191
|
+
path: "delivery",
|
|
192
|
+
group: "Team",
|
|
193
|
+
summary: "Client hand-over: is this the team repository (push here, the client sees only your organization) or the client's (do not push)? Exits 4 in the client's repository, so it works as a pre-push hook.",
|
|
194
|
+
tool: "get_client_delivery",
|
|
195
|
+
flags: [REPO_FLAG],
|
|
196
|
+
async run(ctx, inv) {
|
|
197
|
+
const repo = repoOf(ctx, inv);
|
|
198
|
+
const data = await ctx.call("get_client_delivery", { repo });
|
|
199
|
+
const rows = (data.deliveries ?? []);
|
|
200
|
+
const d = rows[0];
|
|
201
|
+
const blocked = Boolean(d) && d.push_here !== true;
|
|
202
|
+
emit(ctx, data, () => {
|
|
203
|
+
if (!d) {
|
|
204
|
+
ctx.out.log(`${repo} is not handed over to a client: nothing republishes your commits under the organization's name.`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (blocked)
|
|
208
|
+
ctx.out.warn(String(d.guidance ?? "Do not push here."));
|
|
209
|
+
else
|
|
210
|
+
ctx.out.log(ctx.out.bold(String(d.guidance ?? "")));
|
|
211
|
+
ctx.out.log("");
|
|
212
|
+
ctx.out.table(["team repository", "client's repository", "branch", "client sees", "status", "published"], [[d.team_repo, d.client_repo, d.published_branch, d.published_as, d.status, d.commits_published]].map((r) => r.map((v) => String(v ?? ""))));
|
|
213
|
+
for (const p of (d.problems ?? []))
|
|
214
|
+
ctx.out.warn(String(p));
|
|
215
|
+
if (!blocked)
|
|
216
|
+
for (const r of (d.rules ?? []))
|
|
217
|
+
ctx.out.log(ctx.out.dim(` • ${String(r)}`));
|
|
218
|
+
});
|
|
219
|
+
return blocked ? EXIT.CONFLICT : EXIT.OK;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
export const TEAM_COMMANDS = [teamCommand, overlapCommand, teamReport, handoffCommand, handoffRecord, deliveryCommand];
|
package/dist/mcp/client.js
CHANGED
|
@@ -50,7 +50,7 @@ export class McpClient {
|
|
|
50
50
|
const body = { jsonrpc: "2.0", id: this.nextId++, method, params };
|
|
51
51
|
let res = await this.post(token, body);
|
|
52
52
|
if (res.status === 401) {
|
|
53
|
-
const refreshed = await this.tokens.refresh();
|
|
53
|
+
const refreshed = await this.tokens.refresh(token);
|
|
54
54
|
if (!refreshed) {
|
|
55
55
|
throw authError("Your ScribeClock session is no longer valid (the token was rejected).", "Run `scribeclock login` again. If you use SCRIBECLOCK_TOKEN, create a new personal access token.");
|
|
56
56
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scribeclock",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "ScribeClock from the terminal: tasks, time clock, projects, PRDs, meetings, files, vault and Cloudflare — a thin client over the ScribeClock MCP server.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Haydar Al-Karar LLC, dba Karar Tech",
|