mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
feat(discord): testable codeman notify, short-id route, wiki page, docs and glue tests
Moves the notify command into src/cli-notify.ts with tests, lets discord-notify take the 8-character id a Docker case exports, adds the wiki page and index entries, rewrites the stale changeset and security notes, and pins the WebServer wiring.
This commit is contained in:
1 parent
e3ca07d59d
commit
902d4491e5
15 files changed
+473
-76
No files matched your search
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify).
|
||||
*
|
||||
* A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out
|
||||
* wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every
|
||||
* session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to
|
||||
* print and the exit code instead of exiting, so a test can drive it.
|
||||
*
|
||||
* @module cli-notify
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
|
||||
export interface NotifyOptions {
|
||||
message: string;
|
||||
urgent?: boolean;
|
||||
file?: string;
|
||||
session?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface NotifyResult {
|
||||
code: number;
|
||||
out?: string;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
export type JsonPost = (
|
||||
apiUrl: string,
|
||||
apiPath: string,
|
||||
payload: unknown
|
||||
) => Promise<{ status: number; error?: string }>;
|
||||
|
||||
/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */
|
||||
export const postJson: JsonPost = (apiUrl, apiPath, payload) => {
|
||||
const { username, password } = readCodemanCredentials();
|
||||
const url = new URL(apiPath, apiUrl);
|
||||
const body = JSON.stringify(payload);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve) => {
|
||||
const headers: Record<string, string | number> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: 'POST',
|
||||
path: `${url.pathname}${url.search}`,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let error: string | undefined;
|
||||
try {
|
||||
error = (JSON.parse(text) as { error?: string }).error;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, error });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', () => resolve({ status: 0 }));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
export async function runNotify(
|
||||
opts: NotifyOptions,
|
||||
env: NodeJS.ProcessEnv,
|
||||
cwd: string,
|
||||
post: JsonPost = postJson
|
||||
): Promise<NotifyResult> {
|
||||
const sessionId = opts.session || env.CODEMAN_SESSION_ID;
|
||||
const apiUrl = opts.url || env.CODEMAN_API_URL;
|
||||
if (!sessionId || !apiUrl) {
|
||||
return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' };
|
||||
}
|
||||
const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message };
|
||||
if (opts.urgent) payload.urgent = true;
|
||||
if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file);
|
||||
const { status, error } = await post(
|
||||
apiUrl,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
|
||||
payload
|
||||
);
|
||||
if (status >= 200 && status < 300) return { code: 0, out: 'Posted to Discord' };
|
||||
// The bridge being off is not a failure: the caller just says it in the terminal.
|
||||
if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' };
|
||||
return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` };
|
||||
}
|
||||
+5
-71
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
import { runNotify } from './cli-notify.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import {
|
||||
@@ -92,53 +93,6 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin
|
||||
});
|
||||
}
|
||||
|
||||
/** POST JSON to the Codeman API with the same credentials `attach` uses. Resolves to the HTTP status, or 0. */
|
||||
async function postJson(
|
||||
apiUrl: string,
|
||||
apiPath: string,
|
||||
payload: unknown
|
||||
): Promise<{ status: number; error?: string }> {
|
||||
const { username, password } = readCodemanCredentials();
|
||||
const url = new URL(apiPath, apiUrl);
|
||||
const body = JSON.stringify(payload);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve) => {
|
||||
const headers: Record<string, string | number> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: 'POST',
|
||||
path: `${url.pathname}${url.search}`,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let error: string | undefined;
|
||||
try {
|
||||
error = (JSON.parse(text) as { error?: string }).error;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, error });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', () => resolve({ status: 0 }));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
program
|
||||
.command('notify <message>')
|
||||
.description(
|
||||
@@ -149,30 +103,10 @@ program
|
||||
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
|
||||
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL)')
|
||||
.action(async (message: string, options) => {
|
||||
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
|
||||
const apiUrl = options.url || process.env.CODEMAN_API_URL;
|
||||
if (!sessionId || !apiUrl) {
|
||||
console.error(palette.err('✗ notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)'));
|
||||
process.exit(1);
|
||||
}
|
||||
const payload: { message: string; urgent?: boolean; file?: string } = { message };
|
||||
if (options.urgent) payload.urgent = true;
|
||||
if (options.file) payload.file = isAbsolute(options.file) ? options.file : join(process.cwd(), options.file);
|
||||
const { status, error } = await postJson(
|
||||
apiUrl,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
|
||||
payload
|
||||
);
|
||||
if (status >= 200 && status < 300) {
|
||||
console.log(palette.ok('✓ Posted to Discord'));
|
||||
} else if (status === 409) {
|
||||
console.log('The Discord bridge is off. Say it in the terminal instead.');
|
||||
} else {
|
||||
console.error(
|
||||
palette.err(`✗ Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd());
|
||||
if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out);
|
||||
if (err) console.error(palette.err(`✗ ${err}`));
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
program
|
||||
|
||||
@@ -46,6 +46,16 @@ export type DiscordStatus = Omit<DiscordConfig, 'token'> & {
|
||||
lastResult: DiscordResult | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at
|
||||
* least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id.
|
||||
*/
|
||||
function resolveSessionId(ctx: SessionPort, id: string): string {
|
||||
if (ctx.sessions.has(id) || id.length < 8) return id;
|
||||
const hits = [...ctx.sessions.keys()].filter((k) => k.startsWith(id));
|
||||
return hits.length === 1 ? hits[0] : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks
|
||||
* followed) lies inside the session's working folder. Anything else is refused, so a CLI cannot be
|
||||
@@ -137,7 +147,8 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { id: rawId } = req.params as { id: string };
|
||||
const id = resolveSessionId(deps.ctx, rawId);
|
||||
const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message');
|
||||
const session = findSessionOrFail(deps.ctx, id, req);
|
||||
let upload: { name: string; data: Uint8Array } | undefined;
|
||||
|
||||
Reference in new issue
Block a user