thumbgate 1.1.0 → 1.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +16 -5
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/mcp/server-stdio.js +10 -7
- package/adapters/opencode/opencode.json +1 -1
- package/config/github-about.json +1 -1
- package/package.json +20 -11
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-codex-bridge/.mcp.json +1 -1
- package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-profile/.mcp.json +1 -1
- package/plugins/codex-profile/INSTALL.md +1 -1
- package/plugins/codex-profile/README.md +1 -1
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
- package/plugins/opencode-profile/INSTALL.md +1 -1
- package/public/compare.html +302 -0
- package/public/index.html +36 -10
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
- package/scripts/ai-search-visibility.js +142 -0
- package/scripts/changeset-check.js +372 -0
- package/scripts/check-congruence.js +7 -4
- package/scripts/computer-use-firewall.js +45 -15
- package/scripts/docker-sandbox-planner.js +208 -0
- package/scripts/github-about.js +56 -0
- package/scripts/operational-integrity.js +7 -1
- package/scripts/published-cli.js +10 -1
- package/scripts/statusline-links.js +238 -0
- package/scripts/statusline.sh +39 -4
- package/scripts/sync-github-about.js +7 -4
- package/scripts/workflow-sentinel.js +83 -35
- package/src/api/server.js +12 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { classifyCommand } = require('./operational-integrity');
|
|
7
|
+
|
|
8
|
+
const HIGH_RISK_ACTION_TYPES = new Set([
|
|
9
|
+
'shell.exec',
|
|
10
|
+
'file.delete',
|
|
11
|
+
'upload',
|
|
12
|
+
'message.send',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function normalizeText(value) {
|
|
16
|
+
if (value === undefined || value === null) return '';
|
|
17
|
+
return String(value).trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeStringArray(values = []) {
|
|
21
|
+
if (!Array.isArray(values)) return [];
|
|
22
|
+
return Array.from(new Set(
|
|
23
|
+
values
|
|
24
|
+
.map((value) => normalizeText(value))
|
|
25
|
+
.filter(Boolean),
|
|
26
|
+
));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeRiskBand(value) {
|
|
30
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
31
|
+
if (['very_high', 'high', 'medium', 'low'].includes(normalized)) {
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
if (normalized === 'critical') return 'very_high';
|
|
35
|
+
return 'low';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function quoteShellArg(value) {
|
|
39
|
+
return `'${String(value).replaceAll('\'', String.raw`'\''`)}'`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildNetworkPolicy(input = {}) {
|
|
43
|
+
const allowedHosts = normalizeStringArray(input.allowedHosts || input.egressAllowlist);
|
|
44
|
+
if (input.requiresNetwork !== true) {
|
|
45
|
+
return {
|
|
46
|
+
mode: 'deny_all',
|
|
47
|
+
allowedHosts: [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
mode: allowedHosts.length > 0 ? 'allow_list' : 'egress_enabled',
|
|
52
|
+
allowedHosts,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildLaunchers(workspacePath) {
|
|
57
|
+
const suffix = workspacePath ? ` shell ${quoteShellArg(workspacePath)}` : ' shell';
|
|
58
|
+
return {
|
|
59
|
+
standalone: `sbx run${suffix}`,
|
|
60
|
+
dockerDesktop: `docker sandbox run${suffix}`,
|
|
61
|
+
followUp: workspacePath
|
|
62
|
+
? [
|
|
63
|
+
'sbx list',
|
|
64
|
+
'docker sandbox ls',
|
|
65
|
+
]
|
|
66
|
+
: [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildSummary(shouldSandbox, recommendation) {
|
|
71
|
+
if (!shouldSandbox) {
|
|
72
|
+
return 'Current action can stay on the normal local execution path.';
|
|
73
|
+
}
|
|
74
|
+
if (recommendation === 'required') {
|
|
75
|
+
return 'Route this action into Docker Sandboxes before retrying so the run happens inside a disposable microVM instead of on the host.';
|
|
76
|
+
}
|
|
77
|
+
return 'Prefer Docker Sandboxes for this action to reduce host blast radius while keeping local autonomy.';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildWhy({
|
|
81
|
+
recommendation,
|
|
82
|
+
command,
|
|
83
|
+
riskBand,
|
|
84
|
+
actionType,
|
|
85
|
+
affectedFiles,
|
|
86
|
+
}) {
|
|
87
|
+
const lines = [];
|
|
88
|
+
if (recommendation === 'required') {
|
|
89
|
+
lines.push('The predicted action is destructive or release-sensitive enough to justify host isolation.');
|
|
90
|
+
} else if (recommendation === 'recommended') {
|
|
91
|
+
lines.push('The predicted action is high-risk enough that isolated execution meaningfully reduces host blast radius.');
|
|
92
|
+
} else {
|
|
93
|
+
lines.push('The current action does not need a dedicated Docker sandbox boundary.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (command && /\brm\s+-rf\b/i.test(command)) {
|
|
97
|
+
lines.push('Recursive delete commands are safer when the filesystem boundary lives inside a disposable microVM.');
|
|
98
|
+
}
|
|
99
|
+
if (command && /\bgit\s+push\b.*(?:--force|-f)\b/i.test(command)) {
|
|
100
|
+
lines.push('Force-push flows should run in an isolated lane so host credentials and unrelated state stay out of scope.');
|
|
101
|
+
}
|
|
102
|
+
if (command && /\b(?:gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i.test(command)) {
|
|
103
|
+
lines.push('PR, merge, and publish flows are governance-sensitive and benefit from a disposable execution boundary.');
|
|
104
|
+
}
|
|
105
|
+
if (HIGH_RISK_ACTION_TYPES.has(actionType)) {
|
|
106
|
+
lines.push(`Action type ${actionType} is in the high-risk set for local execution.`);
|
|
107
|
+
}
|
|
108
|
+
if (riskBand === 'very_high' || riskBand === 'high') {
|
|
109
|
+
lines.push(`Risk band ${riskBand} predicts elevated blast radius on the local host.`);
|
|
110
|
+
}
|
|
111
|
+
if (affectedFiles.length >= 4) {
|
|
112
|
+
lines.push(`The change touches ${affectedFiles.length} files, so host isolation improves recovery if the run goes sideways.`);
|
|
113
|
+
}
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildDockerSandboxPlan(input = {}) {
|
|
118
|
+
const toolName = normalizeText(input.toolName);
|
|
119
|
+
const actionType = normalizeText(input.actionType)
|
|
120
|
+
|| (toolName === 'Bash' ? 'shell.exec' : '');
|
|
121
|
+
const command = normalizeText(input.command);
|
|
122
|
+
const repoPath = normalizeText(input.repoPath);
|
|
123
|
+
const workspacePath = repoPath ? path.resolve(repoPath) : null;
|
|
124
|
+
const affectedFiles = normalizeStringArray(input.affectedFiles || input.changedFiles || input.files);
|
|
125
|
+
const riskBand = normalizeRiskBand(input.riskBand || input.band);
|
|
126
|
+
const riskScore = Number.isFinite(Number(input.riskScore))
|
|
127
|
+
? Number(Number(input.riskScore).toFixed(4))
|
|
128
|
+
: null;
|
|
129
|
+
const commandInfo = classifyCommand(command);
|
|
130
|
+
const destructiveCommand = /\brm\s+-rf\b/i.test(command)
|
|
131
|
+
|| /\bgit\s+push\b.*(?:--force|-f)\b/i.test(command)
|
|
132
|
+
|| /\bgh\s+pr\s+merge\b.*--admin\b/i.test(command);
|
|
133
|
+
const governedCommand = Boolean(
|
|
134
|
+
commandInfo.isPrCreate
|
|
135
|
+
|| commandInfo.isPrMerge
|
|
136
|
+
|| commandInfo.isPublish
|
|
137
|
+
|| commandInfo.isReleaseCreate
|
|
138
|
+
|| commandInfo.isTagCreate
|
|
139
|
+
);
|
|
140
|
+
const highRiskAction = HIGH_RISK_ACTION_TYPES.has(actionType)
|
|
141
|
+
|| destructiveCommand
|
|
142
|
+
|| governedCommand
|
|
143
|
+
|| riskBand === 'high'
|
|
144
|
+
|| riskBand === 'very_high';
|
|
145
|
+
|
|
146
|
+
let recommendation = 'not_needed';
|
|
147
|
+
if (destructiveCommand || commandInfo.isPublish || commandInfo.isReleaseCreate || actionType === 'upload' || actionType === 'message.send') {
|
|
148
|
+
recommendation = 'required';
|
|
149
|
+
} else if (highRiskAction || affectedFiles.length >= 4) {
|
|
150
|
+
recommendation = 'recommended';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const shouldSandbox = recommendation !== 'not_needed';
|
|
154
|
+
const networkPolicy = buildNetworkPolicy({
|
|
155
|
+
requiresNetwork: input.requiresNetwork === true || governedCommand || commandInfo.isPublish || actionType === 'upload' || actionType === 'message.send',
|
|
156
|
+
allowedHosts: input.allowedHosts,
|
|
157
|
+
egressAllowlist: input.egressAllowlist,
|
|
158
|
+
});
|
|
159
|
+
const launchers = buildLaunchers(workspacePath);
|
|
160
|
+
const summary = buildSummary(shouldSandbox, recommendation);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
plannerVersion: 'docker-sandbox-plan-v1',
|
|
164
|
+
shouldSandbox,
|
|
165
|
+
recommendation,
|
|
166
|
+
summary,
|
|
167
|
+
sandboxKind: shouldSandbox ? 'docker_microvm' : 'host',
|
|
168
|
+
workspacePath,
|
|
169
|
+
actionType: actionType || null,
|
|
170
|
+
riskBand,
|
|
171
|
+
riskScore,
|
|
172
|
+
command: command || null,
|
|
173
|
+
affectedFiles,
|
|
174
|
+
networkPolicy,
|
|
175
|
+
launchers,
|
|
176
|
+
claims: shouldSandbox ? {
|
|
177
|
+
isolationBoundary: 'microvm',
|
|
178
|
+
hostAccess: 'bounded_outside_host',
|
|
179
|
+
dockerDaemon: 'private_inside_sandbox',
|
|
180
|
+
workspaceStrategy: workspacePath ? 'directory_sync' : 'ephemeral',
|
|
181
|
+
} : null,
|
|
182
|
+
why: buildWhy({
|
|
183
|
+
recommendation,
|
|
184
|
+
command,
|
|
185
|
+
riskBand,
|
|
186
|
+
actionType,
|
|
187
|
+
affectedFiles,
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
HIGH_RISK_ACTION_TYPES,
|
|
194
|
+
buildDockerSandboxPlan,
|
|
195
|
+
buildLaunchers,
|
|
196
|
+
buildNetworkPolicy,
|
|
197
|
+
buildSummary,
|
|
198
|
+
normalizeRiskBand,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (require.main === module) {
|
|
202
|
+
const plan = buildDockerSandboxPlan({
|
|
203
|
+
toolName: process.argv[2] || 'Bash',
|
|
204
|
+
command: process.argv.slice(3).join(' '),
|
|
205
|
+
repoPath: process.cwd(),
|
|
206
|
+
});
|
|
207
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
208
|
+
}
|
package/scripts/github-about.js
CHANGED
|
@@ -12,6 +12,8 @@ const ROOT = path.join(__dirname, '..');
|
|
|
12
12
|
const CONFIG_RELATIVE_PATH = path.join('config', 'github-about.json');
|
|
13
13
|
const LEGACY_REPOSITORY_URL = 'https://github.com/IgorGanapolsky/thumbgate';
|
|
14
14
|
const GITHUB_API_BASE_URL = 'https://api.github.com';
|
|
15
|
+
const DEFAULT_VERIFY_ATTEMPTS = 5;
|
|
16
|
+
const DEFAULT_VERIFY_DELAY_MS = 2000;
|
|
15
17
|
|
|
16
18
|
function readText(root, relativePath) {
|
|
17
19
|
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
|
@@ -296,6 +298,57 @@ async function fetchLiveGitHubAbout(options = {}) {
|
|
|
296
298
|
};
|
|
297
299
|
}
|
|
298
300
|
|
|
301
|
+
function normalizePositiveInteger(value, fallback) {
|
|
302
|
+
const parsed = Number.parseInt(value, 10);
|
|
303
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sleep(delayMs) {
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
setTimeout(resolve, delayMs);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function verifyLiveGitHubAbout(options = {}) {
|
|
313
|
+
const root = options.root || ROOT;
|
|
314
|
+
const expected = options.expected || loadGitHubAboutConfig(root);
|
|
315
|
+
const repo = normalizeText(options.repo) || expected.repo;
|
|
316
|
+
const label = options.label || `Live GitHub About (${repo})`;
|
|
317
|
+
const attempts = normalizePositiveInteger(options.attempts, DEFAULT_VERIFY_ATTEMPTS);
|
|
318
|
+
const delayMs = normalizePositiveInteger(options.delayMs, DEFAULT_VERIFY_DELAY_MS);
|
|
319
|
+
const fetcher = typeof options.fetcher === 'function' ? options.fetcher : fetchLiveGitHubAbout;
|
|
320
|
+
const sleeper = typeof options.sleep === 'function' ? options.sleep : sleep;
|
|
321
|
+
let actual = null;
|
|
322
|
+
let errors = [];
|
|
323
|
+
|
|
324
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
325
|
+
actual = await fetcher({
|
|
326
|
+
root,
|
|
327
|
+
repo,
|
|
328
|
+
token: options.token,
|
|
329
|
+
});
|
|
330
|
+
errors = compareGitHubAbout(expected, actual, label);
|
|
331
|
+
if (errors.length === 0) {
|
|
332
|
+
return {
|
|
333
|
+
ok: true,
|
|
334
|
+
actual,
|
|
335
|
+
attemptsUsed: attempt,
|
|
336
|
+
errors: [],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (attempt < attempts) {
|
|
340
|
+
await sleeper(delayMs * attempt);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
actual,
|
|
347
|
+
attemptsUsed: attempts,
|
|
348
|
+
errors,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
299
352
|
async function updateLiveGitHubAbout(options = {}) {
|
|
300
353
|
const about = loadGitHubAboutConfig(options.root || ROOT);
|
|
301
354
|
const repo = normalizeText(options.repo) || about.repo;
|
|
@@ -334,6 +387,8 @@ async function updateLiveGitHubAbout(options = {}) {
|
|
|
334
387
|
}
|
|
335
388
|
|
|
336
389
|
module.exports = {
|
|
390
|
+
DEFAULT_VERIFY_ATTEMPTS,
|
|
391
|
+
DEFAULT_VERIFY_DELAY_MS,
|
|
337
392
|
LEGACY_REPOSITORY_URL,
|
|
338
393
|
buildCanonicalRepoUrls,
|
|
339
394
|
collectLocalGitHubAboutErrors,
|
|
@@ -347,4 +402,5 @@ module.exports = {
|
|
|
347
402
|
normalizeTopics,
|
|
348
403
|
normalizeUrl,
|
|
349
404
|
updateLiveGitHubAbout,
|
|
405
|
+
verifyLiveGitHubAbout,
|
|
350
406
|
};
|
|
@@ -457,12 +457,17 @@ function parseCliArgs(argv = process.argv.slice(2)) {
|
|
|
457
457
|
return options;
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
function resolveCiBranchName(env = process.env) {
|
|
461
|
+
const branchName = String(env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || '').trim();
|
|
462
|
+
return branchName || undefined;
|
|
463
|
+
}
|
|
464
|
+
|
|
460
465
|
function runCli(env = process.env, argv = process.argv.slice(2)) {
|
|
461
466
|
const args = parseCliArgs(argv);
|
|
462
467
|
const result = evaluateOperationalIntegrity({
|
|
463
468
|
repoPath: args.repoPath,
|
|
464
469
|
baseBranch: args.baseBranch || env.DEFAULT_BRANCH || DEFAULT_BASE_BRANCH,
|
|
465
|
-
currentBranch: env
|
|
470
|
+
currentBranch: resolveCiBranchName(env),
|
|
466
471
|
requirePrForReleaseSensitive: args.requirePrForReleaseSensitive,
|
|
467
472
|
requireVersionNotBehindBase: args.requireVersionNotBehindBase,
|
|
468
473
|
fetchBase: args.fetchBase,
|
|
@@ -517,6 +522,7 @@ module.exports = {
|
|
|
517
522
|
parseSemver,
|
|
518
523
|
readPackageVersion,
|
|
519
524
|
resolveBaseRef,
|
|
525
|
+
resolveCiBranchName,
|
|
520
526
|
resolveRepoRoot,
|
|
521
527
|
runCli,
|
|
522
528
|
sanitizeGlobList,
|
package/scripts/published-cli.js
CHANGED
|
@@ -13,6 +13,10 @@ function runtimePrefixDir(prefixDir) {
|
|
|
13
13
|
return prefixDir || path.join(os.homedir(), '.thumbgate', 'runtime');
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function installedRuntimeBin(prefixDir) {
|
|
17
|
+
return path.join(runtimePrefixDir(prefixDir), 'node_modules', '.bin', 'thumbgate');
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
|
|
17
21
|
return [
|
|
18
22
|
'exec',
|
|
@@ -29,7 +33,11 @@ function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
|
|
|
29
33
|
|
|
30
34
|
function publishedCliShellCommand(pkgVersion, commandArgs = [], options = {}) {
|
|
31
35
|
const prefixDir = runtimePrefixDir(options.prefixDir);
|
|
32
|
-
|
|
36
|
+
const runtimeBin = installedRuntimeBin(prefixDir);
|
|
37
|
+
const escapedArgs = commandArgs.map(shellQuote).join(' ');
|
|
38
|
+
const fastPath = `[ -x ${shellQuote(runtimeBin)} ] && exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`;
|
|
39
|
+
const installPath = `mkdir -p ${shellQuote(prefixDir)} && exec npm ${publishedCliArgs(pkgVersion, commandArgs, { prefixDir }).map(shellQuote).join(' ')}`;
|
|
40
|
+
return `${fastPath} || ${installPath}`;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
function runPublishedCli(pkgVersion, commandArgs = [], options = {}) {
|
|
@@ -55,6 +63,7 @@ function runPublishedCliHelp(pkgVersion, options = {}) {
|
|
|
55
63
|
module.exports = {
|
|
56
64
|
publishedCliArgs,
|
|
57
65
|
publishedCliShellCommand,
|
|
66
|
+
installedRuntimeBin,
|
|
58
67
|
runtimePrefixDir,
|
|
59
68
|
runPublishedCli,
|
|
60
69
|
runPublishedCliHelp,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const { spawn } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const { getHomeDir, getRuntimeDir, resolveProjectDir } = require('./feedback-paths');
|
|
11
|
+
const { resolveProKey } = require('./pro-local-dashboard');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_ORIGIN = 'http://localhost:3456';
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 150;
|
|
15
|
+
const DEFAULT_BOOT_GRACE_MS = 5000;
|
|
16
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
17
|
+
|
|
18
|
+
function parseOrigin(origin) {
|
|
19
|
+
const url = new URL(origin || DEFAULT_ORIGIN);
|
|
20
|
+
return {
|
|
21
|
+
origin: url.origin,
|
|
22
|
+
host: url.hostname,
|
|
23
|
+
port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
|
|
24
|
+
protocol: url.protocol,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isLoopbackHost(host) {
|
|
29
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runtimeStatePath(options = {}) {
|
|
33
|
+
return path.join(getRuntimeDir(options), 'statusline-api.json');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readRuntimeState(options = {}) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(fs.readFileSync(runtimeStatePath(options), 'utf8'));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeRuntimeState(payload, options = {}) {
|
|
45
|
+
const targetPath = runtimeStatePath(options);
|
|
46
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
47
|
+
fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2) + '\n');
|
|
48
|
+
return targetPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isPidAlive(pid) {
|
|
52
|
+
const numericPid = Number(pid);
|
|
53
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
|
|
54
|
+
try {
|
|
55
|
+
process.kill(numericPid, 0);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shouldReuseBootingState(state, now = Date.now()) {
|
|
63
|
+
if (!state || !isPidAlive(state.pid)) return false;
|
|
64
|
+
const startedAt = Date.parse(state.startedAt || 0);
|
|
65
|
+
if (!Number.isFinite(startedAt)) return true;
|
|
66
|
+
return now - startedAt < DEFAULT_BOOT_GRACE_MS;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requestOk(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const req = http.get(url, (res) => {
|
|
72
|
+
res.resume();
|
|
73
|
+
resolve(res.statusCode >= 200 && res.statusCode < 500);
|
|
74
|
+
});
|
|
75
|
+
req.on('error', () => resolve(false));
|
|
76
|
+
req.setTimeout(timeoutMs, () => {
|
|
77
|
+
req.destroy();
|
|
78
|
+
resolve(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function probeLocalServer(origin, options = {}) {
|
|
84
|
+
const timeoutMs = Number(options.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
85
|
+
return requestOk(`${origin}/health`, timeoutMs);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function launchLocalServer(options = {}) {
|
|
89
|
+
const env = options.env || process.env;
|
|
90
|
+
const origin = parseOrigin(options.origin || env.THUMBGATE_LOCAL_API_ORIGIN || DEFAULT_ORIGIN);
|
|
91
|
+
const homeDir = options.homeDir || getHomeDir({ env });
|
|
92
|
+
const resolvedKey = (options.resolveKey || resolveProKey)({ env, homeDir });
|
|
93
|
+
const projectDir = resolveProjectDir({ env, cwd: options.cwd || process.cwd() });
|
|
94
|
+
const childEnv = {
|
|
95
|
+
...env,
|
|
96
|
+
HOST: origin.host,
|
|
97
|
+
PORT: String(origin.port),
|
|
98
|
+
THUMBGATE_LOCAL_API_ORIGIN: origin.origin,
|
|
99
|
+
THUMBGATE_PROJECT_DIR: projectDir,
|
|
100
|
+
THUMBGATE_PRO_MODE: '1',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (resolvedKey && resolvedKey.key) {
|
|
104
|
+
childEnv.THUMBGATE_API_KEY = resolvedKey.key;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const child = spawn(
|
|
108
|
+
process.execPath,
|
|
109
|
+
[path.join(PKG_ROOT, 'bin', 'cli.js'), 'start-api'],
|
|
110
|
+
{
|
|
111
|
+
cwd: projectDir,
|
|
112
|
+
env: childEnv,
|
|
113
|
+
detached: true,
|
|
114
|
+
stdio: 'ignore',
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
child.unref();
|
|
118
|
+
|
|
119
|
+
const state = {
|
|
120
|
+
pid: child.pid,
|
|
121
|
+
projectDir,
|
|
122
|
+
origin: origin.origin,
|
|
123
|
+
startedAt: new Date().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
writeRuntimeState(state, { env, home: homeDir });
|
|
126
|
+
return state;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildLinkState({
|
|
130
|
+
ready,
|
|
131
|
+
booting,
|
|
132
|
+
origin,
|
|
133
|
+
canBootstrap,
|
|
134
|
+
}) {
|
|
135
|
+
if (ready) {
|
|
136
|
+
return {
|
|
137
|
+
state: 'ready',
|
|
138
|
+
dashboardLabel: 'Dashboard',
|
|
139
|
+
lessonsLabel: 'Lessons',
|
|
140
|
+
upLabel: '👍',
|
|
141
|
+
downLabel: '👎',
|
|
142
|
+
dashboardUrl: `${origin}/dashboard`,
|
|
143
|
+
lessonsUrl: `${origin}/lessons`,
|
|
144
|
+
upUrl: `${origin}/feedback/quick?signal=up`,
|
|
145
|
+
downUrl: `${origin}/feedback/quick?signal=down`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (booting) {
|
|
150
|
+
return {
|
|
151
|
+
state: 'booting',
|
|
152
|
+
dashboardLabel: 'Dashboard…',
|
|
153
|
+
lessonsLabel: 'Lessons…',
|
|
154
|
+
upLabel: '👍',
|
|
155
|
+
downLabel: '👎',
|
|
156
|
+
dashboardUrl: '',
|
|
157
|
+
lessonsUrl: '',
|
|
158
|
+
upUrl: '',
|
|
159
|
+
downUrl: '',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
state: canBootstrap ? 'offline' : 'unavailable',
|
|
165
|
+
dashboardLabel: canBootstrap ? 'Dash: thumbgate pro' : 'Dashboard',
|
|
166
|
+
lessonsLabel: 'Learn: thumbgate lessons',
|
|
167
|
+
upLabel: '👍',
|
|
168
|
+
downLabel: '👎',
|
|
169
|
+
dashboardUrl: '',
|
|
170
|
+
lessonsUrl: '',
|
|
171
|
+
upUrl: '',
|
|
172
|
+
downUrl: '',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function getStatuslineLinks(options = {}) {
|
|
177
|
+
const env = options.env || process.env;
|
|
178
|
+
if (env._TEST_THUMBGATE_STATUSLINE_LINKS_JSON) {
|
|
179
|
+
return JSON.parse(env._TEST_THUMBGATE_STATUSLINE_LINKS_JSON);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const homeDir = options.homeDir || getHomeDir({ env });
|
|
183
|
+
const parsedOrigin = parseOrigin(options.origin || env.THUMBGATE_LOCAL_API_ORIGIN || DEFAULT_ORIGIN);
|
|
184
|
+
const origin = parsedOrigin.origin;
|
|
185
|
+
const allowLocalBootstrap = isLoopbackHost(parsedOrigin.host);
|
|
186
|
+
const probe = options.probeLocalServer || probeLocalServer;
|
|
187
|
+
const resolveKey = options.resolveKey || resolveProKey;
|
|
188
|
+
const startServer = options.launchLocalServer || launchLocalServer;
|
|
189
|
+
const key = resolveKey({ env, homeDir });
|
|
190
|
+
const canBootstrap = allowLocalBootstrap && Boolean(key && key.key);
|
|
191
|
+
|
|
192
|
+
const ready = allowLocalBootstrap ? await probe(origin, options) : false;
|
|
193
|
+
if (ready) {
|
|
194
|
+
return buildLinkState({ ready: true, booting: false, origin, canBootstrap });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const state = readRuntimeState({ env, home: homeDir });
|
|
198
|
+
if (shouldReuseBootingState(state)) {
|
|
199
|
+
return buildLinkState({ ready: false, booting: true, origin, canBootstrap });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (canBootstrap) {
|
|
203
|
+
startServer({
|
|
204
|
+
env,
|
|
205
|
+
homeDir,
|
|
206
|
+
origin,
|
|
207
|
+
cwd: options.cwd || process.cwd(),
|
|
208
|
+
resolveKey: () => key,
|
|
209
|
+
});
|
|
210
|
+
return buildLinkState({ ready: false, booting: true, origin, canBootstrap });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return buildLinkState({ ready: false, booting: false, origin, canBootstrap });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (require.main === module) {
|
|
217
|
+
getStatuslineLinks()
|
|
218
|
+
.then((payload) => {
|
|
219
|
+
process.stdout.write(JSON.stringify(payload));
|
|
220
|
+
})
|
|
221
|
+
.catch(() => {
|
|
222
|
+
process.exit(0);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
buildLinkState,
|
|
228
|
+
getStatuslineLinks,
|
|
229
|
+
isPidAlive,
|
|
230
|
+
launchLocalServer,
|
|
231
|
+
parseOrigin,
|
|
232
|
+
isLoopbackHost,
|
|
233
|
+
probeLocalServer,
|
|
234
|
+
readRuntimeState,
|
|
235
|
+
runtimeStatePath,
|
|
236
|
+
shouldReuseBootingState,
|
|
237
|
+
writeRuntimeState,
|
|
238
|
+
};
|
package/scripts/statusline.sh
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# Resolve script directory safely (CodeQL: no uncontrolled paths)
|
|
7
7
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
8
8
|
case "$SCRIPT_DIR" in *[!a-zA-Z0-9/_.-]*) echo "ThumbGate: invalid script path"; exit 1;; esac
|
|
9
|
+
LOCAL_API_ORIGIN="${THUMBGATE_LOCAL_API_ORIGIN:-http://localhost:3456}"
|
|
9
10
|
|
|
10
11
|
# ── Parse Claude Code session JSON from stdin ─────────────────────
|
|
11
12
|
eval "$(cat | jq -r '
|
|
@@ -63,7 +64,7 @@ fi
|
|
|
63
64
|
# Background refresh from REST API when cache is stale (>120s)
|
|
64
65
|
if [ $(( _NOW - ${CACHE_TS:-0} )) -gt 120 ]; then
|
|
65
66
|
(
|
|
66
|
-
_R=$(curl -s --max-time 3 "
|
|
67
|
+
_R=$(curl -s --max-time 3 "${LOCAL_API_ORIGIN}/v1/feedback/stats" -H "Authorization: Bearer ${THUMBGATE_API_KEY:-tg_creator_dev_enterprise}" 2>/dev/null)
|
|
67
68
|
[ -z "$_R" ] && exit 0
|
|
68
69
|
echo "$_R" | python3 -c "
|
|
69
70
|
import json,sys,time,os
|
|
@@ -78,6 +79,23 @@ except:pass
|
|
|
78
79
|
disown 2>/dev/null
|
|
79
80
|
fi
|
|
80
81
|
|
|
82
|
+
# ── Clickable statusline affordances ─────────────────────────────
|
|
83
|
+
LINK_STATE="offline"
|
|
84
|
+
UP_URL=""; DOWN_URL=""; DASHBOARD_URL=""; LESSONS_URL=""
|
|
85
|
+
DASHBOARD_LABEL="Dashboard"; LESSONS_LABEL="Lessons"
|
|
86
|
+
_LINKS_JSON=$(node "${SCRIPT_DIR}/statusline-links.js" 2>/dev/null)
|
|
87
|
+
if [ -n "$_LINKS_JSON" ]; then
|
|
88
|
+
eval "$(echo "$_LINKS_JSON" | jq -r '
|
|
89
|
+
@sh "LINK_STATE=\(.state // "offline")",
|
|
90
|
+
@sh "UP_URL=\(.upUrl // "")",
|
|
91
|
+
@sh "DOWN_URL=\(.downUrl // "")",
|
|
92
|
+
@sh "DASHBOARD_URL=\(.dashboardUrl // "")",
|
|
93
|
+
@sh "LESSONS_URL=\(.lessonsUrl // "")",
|
|
94
|
+
@sh "DASHBOARD_LABEL=\(.dashboardLabel // "Dashboard")",
|
|
95
|
+
@sh "LESSONS_LABEL=\(.lessonsLabel // "Lessons")"
|
|
96
|
+
' 2>/dev/null)"
|
|
97
|
+
fi
|
|
98
|
+
|
|
81
99
|
# ── ThumbGate package metadata ────────────────────────────────────────
|
|
82
100
|
TG_VERSION="unknown"; TG_TIER="Free"
|
|
83
101
|
_META_JSON=$(node "${SCRIPT_DIR}/statusline-meta.js" 2>/dev/null)
|
|
@@ -107,17 +125,34 @@ case "${TREND}" in
|
|
|
107
125
|
improving) ARROW="↗" ;; degrading) ARROW="↘" ;; stable) ARROW="→" ;; *) ARROW="?" ;;
|
|
108
126
|
esac
|
|
109
127
|
|
|
128
|
+
osc8_link() {
|
|
129
|
+
local url="$1"
|
|
130
|
+
local label="$2"
|
|
131
|
+
if [ -n "$url" ]; then
|
|
132
|
+
printf '\033]8;;%s\a%s\033]8;;\a' "$url" "$label"
|
|
133
|
+
else
|
|
134
|
+
printf '%s' "$label"
|
|
135
|
+
fi
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
UP_ICON="$(osc8_link "$UP_URL" "👍")"
|
|
139
|
+
DOWN_ICON="$(osc8_link "$DOWN_URL" "👎")"
|
|
140
|
+
DASHBOARD_LINK="$(osc8_link "$DASHBOARD_URL" "$DASHBOARD_LABEL")"
|
|
141
|
+
LESSONS_LINK="$(osc8_link "$LESSONS_URL" "$LESSONS_LABEL")"
|
|
142
|
+
|
|
110
143
|
# ── Output (single line) ─────────────────────────────────────────
|
|
111
144
|
LINE="ThumbGate v${TG_VERSION} · ${TG_TIER}"
|
|
112
145
|
if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
|
|
113
|
-
|
|
146
|
+
LINE="${D}${LINE} · no feedback yet${RST} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
|
|
147
|
+
printf '%b\n' "$LINE"
|
|
114
148
|
else
|
|
115
|
-
LINE="${LINE} · ${G}${BD}${UP}${RST}
|
|
149
|
+
LINE="${LINE} · ${G}${BD}${UP}${RST}${UP_ICON} ${R}${BD}${DOWN}${RST}${DOWN_ICON} ${ARROW}"
|
|
116
150
|
|
|
117
151
|
# Control Tower alerts (if any)
|
|
118
152
|
[ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
|
|
119
153
|
[ "${AT_RISK:-0}" -gt 0 ] && LINE="${LINE} ${R}${AT_RISK}⚠${RST}"
|
|
120
154
|
[ "${ANOMALIES:-0}" -gt 0 ] && LINE="${LINE} ${R}${ANOMALIES}☠${RST}"
|
|
155
|
+
LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
|
|
121
156
|
|
|
122
|
-
|
|
157
|
+
printf '%b\n' "$LINE"
|
|
123
158
|
fi
|
|
@@ -6,6 +6,7 @@ const {
|
|
|
6
6
|
fetchLiveGitHubAbout,
|
|
7
7
|
loadGitHubAboutConfig,
|
|
8
8
|
updateLiveGitHubAbout,
|
|
9
|
+
verifyLiveGitHubAbout,
|
|
9
10
|
} = require('./github-about');
|
|
10
11
|
|
|
11
12
|
async function main() {
|
|
@@ -32,11 +33,13 @@ async function main() {
|
|
|
32
33
|
console.log(`Syncing GitHub About for ${about.repo}...`);
|
|
33
34
|
await updateLiveGitHubAbout({ repo: about.repo });
|
|
34
35
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const verification = await verifyLiveGitHubAbout({
|
|
37
|
+
expected: about,
|
|
38
|
+
repo: about.repo,
|
|
39
|
+
});
|
|
40
|
+
if (verification.errors.length > 0) {
|
|
38
41
|
console.error(`\n❌ GitHub About sync incomplete for ${about.repo}:\n`);
|
|
39
|
-
for (const error of
|
|
42
|
+
for (const error of verification.errors) {
|
|
40
43
|
console.error(` • ${error}`);
|
|
41
44
|
}
|
|
42
45
|
console.error('');
|