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 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
+ }
@@ -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
- writeJsonPrivate(this.path, { version: 1, profiles: all });
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
- writeJsonPrivate(this.path, { version: 1, profiles: all });
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
- if (fromKeychain)
181
- return fromKeychain;
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) {
@@ -1,9 +1,12 @@
1
- import { authError } from "../cli/errors.js";
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
- const refreshed = await this.refresh();
39
- if (refreshed)
40
- return refreshed;
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
- /** Rotating refresh: both tokens are replaced and saved. A reused/revoked refresh token logs out. */
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
- const creds = await this.credentials();
49
- if (!creds || creds.kind !== "oauth" || !creds.refresh_token || !creds.token_endpoint)
50
- return null;
51
- const result = await refreshTokens(this.opts.fetch, creds.token_endpoint, creds.refresh_token, creds.client_id);
52
- if (!result.ok) {
53
- if (result.error === "invalid_grant" || result.error === "invalid_client") {
54
- await this.opts.store.clear(this.opts.profile);
55
- this.cached = null;
56
- throw authError("Your ScribeClock session was revoked or expired.", "Run `scribeclock login` to sign in again.");
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
- const next = {
61
- ...creds,
62
- access_token: result.tokens.access_token,
63
- refresh_token: result.tokens.refresh_token ?? creds.refresh_token,
64
- expires_at: expiresAt(result.tokens.expires_in, this.now()),
65
- scopes: result.tokens.scope ? result.tokens.scope.split(/\s+/).filter(Boolean) : creds.scopes,
66
- saved_at: this.now().toISOString(),
67
- };
68
- await this.opts.store.save(this.opts.profile, next);
69
- this.cached = next;
70
- this.opts.onRefreshed?.(next);
71
- return next.access_token;
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() {
@@ -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
- { id: "posthog", label: "PostHog", url: u("posthog"), steps: ["Data pipelines → Destinations → Webhook; keep the default body ({ event, person }).", "Filter on the $exception event for error tracking; other events are counted as analytics.", "Optional: header X-ScribeClock-Secret matching the key's webhook secret."] },
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 (the ingest key rides in it, since most tools cannot set headers) and where to paste it. 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.",
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_<"))
@@ -187,4 +187,36 @@ export const handoffRecord = cmd({
187
187
  });
188
188
  },
189
189
  });
190
- export const TEAM_COMMANDS = [teamCommand, overlapCommand, teamReport, handoffCommand, handoffRecord];
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];
@@ -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.0",
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",