- Introduced TEAM_START_TASK IPC channel to facilitate starting tasks and notifying agents. - Updated task creation to include an option for immediate start, enhancing user experience. - Enhanced task notifications with detailed instructions for agents upon task assignment. - Improved team member logs handling and metadata extraction for better task tracking. These changes aim to streamline task management and improve team collaboration efficiency.
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import { getToolsBasePath } from '@main/utils/pathDecoder';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import { atomicWriteAsync } from './atomicWrite';
|
|
|
|
const TOOL_FILE_NAME = 'teamctl.js';
|
|
const TOOL_VERSION = 4;
|
|
|
|
function buildTeamCtlScript(): string {
|
|
const script = String.raw`#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
// Team tools (v${TOOL_VERSION})
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const TOOL_VERSION = ${TOOL_VERSION};
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function formatError(err) {
|
|
if (!err) return 'Unknown error';
|
|
if (typeof err === 'string') return err;
|
|
if (err instanceof Error) return err.message || String(err);
|
|
try {
|
|
return JSON.stringify(err);
|
|
} catch {
|
|
return String(err);
|
|
}
|
|
}
|
|
|
|
function die(message, code = 1) {
|
|
process.stderr.write(String(message).trimEnd() + '\n');
|
|
process.exit(code);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { _: [], flags: {} };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const token = argv[i];
|
|
if (token === '--') {
|
|
out._.push(...argv.slice(i + 1));
|
|
break;
|
|
}
|
|
if (token === '-h' || token === '--help') {
|
|
out.flags.help = true;
|
|
continue;
|
|
}
|
|
if (token.startsWith('--')) {
|
|
const eq = token.indexOf('=');
|
|
if (eq !== -1) {
|
|
const key = token.slice(2, eq);
|
|
const value = token.slice(eq + 1);
|
|
out.flags[key] = value;
|
|
continue;
|
|
}
|
|
const key = token.slice(2);
|
|
const next = argv[i + 1];
|
|
if (next != null && !String(next).startsWith('-')) {
|
|
out.flags[key] = next;
|
|
i++;
|
|
} else {
|
|
out.flags[key] = true;
|
|
}
|
|
continue;
|
|
}
|
|
if (token.startsWith('-') && token.length > 1) {
|
|
// minimal short-flag support
|
|
for (const ch of token.slice(1)) {
|
|
if (ch === 'h') out.flags.help = true;
|
|
}
|
|
continue;
|
|
}
|
|
out._.push(token);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function getHomeDir() {
|
|
return process.env.HOME || process.env.USERPROFILE || '';
|
|
}
|
|
|
|
function getClaudeDir(flags) {
|
|
const raw =
|
|
(typeof flags['claude-dir'] === 'string' && flags['claude-dir']) ||
|
|
(typeof flags['claudeDir'] === 'string' && flags['claudeDir']) ||
|
|
(typeof flags['claude_path'] === 'string' && flags['claude_path']) ||
|
|
'';
|
|
if (raw) return path.resolve(raw);
|
|
const inferred = inferClaudeDirFromScriptPath();
|
|
if (inferred) return inferred;
|
|
const home = getHomeDir();
|
|
if (!home) die('HOME is not set');
|
|
return path.join(home, '.claude');
|
|
}
|
|
|
|
function inferClaudeDirFromScriptPath() {
|
|
// Expected: <claudeDir>/tools/teamctl.js
|
|
const toolsDir = path.dirname(__filename);
|
|
if (path.basename(toolsDir) !== 'tools') return null;
|
|
return path.dirname(toolsDir) || null;
|
|
}
|
|
|
|
function inferTeamNameFromScriptPath() {
|
|
// From ~/.claude/tools/ the team name cannot be inferred — --team is required
|
|
return null;
|
|
}
|
|
|
|
function getTeamName(flags) {
|
|
const explicit =
|
|
(typeof flags.team === 'string' && flags.team.trim()) ||
|
|
(typeof flags['teamName'] === 'string' && flags['teamName'].trim()) ||
|
|
'';
|
|
if (explicit) return explicit;
|
|
const inferred = inferTeamNameFromScriptPath();
|
|
if (inferred) return inferred;
|
|
die('Missing --team (and could not infer team name from script path)');
|
|
}
|
|
|
|
function ensureDir(dirPath) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
|
|
function readJson(filePath, fallback) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
if (err && err.code === 'ENOENT') return fallback;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function atomicWrite(filePath, data) {
|
|
ensureDir(path.dirname(filePath));
|
|
const tmp =
|
|
String(filePath) +
|
|
'.tmp.' +
|
|
String(process.pid) +
|
|
'.' +
|
|
String(Math.random().toString(16).slice(2));
|
|
fs.writeFileSync(tmp, data, 'utf8');
|
|
fs.renameSync(tmp, filePath);
|
|
}
|
|
|
|
function normalizeStatus(value) {
|
|
const v = String(value || '').trim();
|
|
if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v;
|
|
return null;
|
|
}
|
|
|
|
function normalizeColumn(value) {
|
|
const v = String(value || '').trim();
|
|
if (v === 'review' || v === 'approved') return v;
|
|
return null;
|
|
}
|
|
|
|
function getPaths(flags, teamName) {
|
|
const claudeDir = getClaudeDir(flags);
|
|
const teamDir = path.join(claudeDir, 'teams', teamName);
|
|
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
|
const kanbanPath = path.join(teamDir, 'kanban-state.json');
|
|
return { claudeDir, teamDir, tasksDir, kanbanPath };
|
|
}
|
|
|
|
function readTask(paths, taskId) {
|
|
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
|
const task = readJson(taskPath, null);
|
|
if (!task) die('Task not found: ' + String(taskId));
|
|
return { taskPath, task };
|
|
}
|
|
|
|
function writeTask(taskPath, task) {
|
|
atomicWrite(taskPath, JSON.stringify(task, null, 2));
|
|
const verify = readJson(taskPath, null);
|
|
if (!verify) die('Task write verification failed');
|
|
return verify;
|
|
}
|
|
|
|
function setTaskStatus(paths, taskId, status) {
|
|
const normalized = normalizeStatus(status);
|
|
if (!normalized) die('Invalid status: ' + String(status));
|
|
const { taskPath, task } = readTask(paths, taskId);
|
|
task.status = normalized;
|
|
writeTask(taskPath, task);
|
|
}
|
|
|
|
function listTaskIds(tasksDir) {
|
|
let entries = [];
|
|
try {
|
|
entries = fs.readdirSync(tasksDir);
|
|
} catch (err) {
|
|
if (err && err.code === 'ENOENT') return [];
|
|
throw err;
|
|
}
|
|
const ids = [];
|
|
for (const file of entries) {
|
|
if (!file.endsWith('.json')) continue;
|
|
if (file.startsWith('.')) continue;
|
|
const num = Number(file.replace(/\.json$/, ''));
|
|
if (!Number.isFinite(num)) continue;
|
|
ids.push(String(num));
|
|
}
|
|
ids.sort((a, b) => Number(a) - Number(b));
|
|
return ids;
|
|
}
|
|
|
|
function getNextTaskId(paths) {
|
|
const ids = listTaskIds(paths.tasksDir);
|
|
const maxFromFiles = ids.length ? Number(ids[ids.length - 1]) : 0;
|
|
const hwmPath = path.join(paths.tasksDir, '.highwatermark');
|
|
const hwmRaw = readJson(hwmPath, null);
|
|
const maxFromHwm = typeof hwmRaw === 'number' ? hwmRaw : Number(String(hwmRaw || '0').trim());
|
|
const max = Math.max(maxFromFiles, Number.isFinite(maxFromHwm) ? maxFromHwm : 0);
|
|
return String(max + 1);
|
|
}
|
|
|
|
function updateHighwatermark(paths, taskId) {
|
|
const hwmPath = path.join(paths.tasksDir, '.highwatermark');
|
|
atomicWrite(hwmPath, String(taskId));
|
|
}
|
|
|
|
function createTask(paths, flags) {
|
|
const subject = typeof flags.subject === 'string' ? flags.subject.trim() : '';
|
|
if (!subject) die('Missing --subject');
|
|
const description =
|
|
typeof flags.description === 'string'
|
|
? flags.description
|
|
: typeof flags.desc === 'string'
|
|
? flags.desc
|
|
: '';
|
|
const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined;
|
|
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
|
|
const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending');
|
|
const activeForm =
|
|
typeof flags.activeForm === 'string'
|
|
? flags.activeForm
|
|
: typeof flags['active-form'] === 'string'
|
|
? flags['active-form']
|
|
: undefined;
|
|
|
|
ensureDir(paths.tasksDir);
|
|
const nextId = getNextTaskId(paths);
|
|
const taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
|
if (fs.existsSync(taskPath)) die('Task already exists: ' + String(nextId));
|
|
|
|
const task = {
|
|
id: nextId,
|
|
subject,
|
|
description: String(description || subject),
|
|
activeForm: activeForm ? String(activeForm) : undefined,
|
|
owner,
|
|
status,
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
|
|
writeTask(taskPath, task);
|
|
updateHighwatermark(paths, nextId);
|
|
return task;
|
|
}
|
|
|
|
function readKanbanState(paths, teamName) {
|
|
const fallback = { teamName, reviewers: [], tasks: {} };
|
|
const parsed = readJson(paths.kanbanPath, fallback);
|
|
if (!parsed || typeof parsed !== 'object') return fallback;
|
|
const reviewers = Array.isArray(parsed.reviewers)
|
|
? parsed.reviewers.filter((r) => typeof r === 'string' && r.trim())
|
|
: [];
|
|
const tasks = parsed.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {};
|
|
return { teamName, reviewers, tasks };
|
|
}
|
|
|
|
function writeKanbanState(paths, state) {
|
|
atomicWrite(paths.kanbanPath, JSON.stringify(state, null, 2));
|
|
}
|
|
|
|
function setKanbanColumn(paths, teamName, taskId, column) {
|
|
const normalized = normalizeColumn(column);
|
|
if (!normalized) die('Invalid column: ' + String(column));
|
|
const state = readKanbanState(paths, teamName);
|
|
if (normalized === 'review') {
|
|
state.tasks[String(taskId)] = {
|
|
column: 'review',
|
|
reviewStatus: 'pending',
|
|
reviewer: null,
|
|
movedAt: nowIso(),
|
|
};
|
|
} else {
|
|
state.tasks[String(taskId)] = {
|
|
column: 'approved',
|
|
movedAt: nowIso(),
|
|
};
|
|
}
|
|
writeKanbanState(paths, state);
|
|
}
|
|
|
|
function clearKanban(paths, teamName, taskId) {
|
|
const state = readKanbanState(paths, teamName);
|
|
delete state.tasks[String(taskId)];
|
|
writeKanbanState(paths, state);
|
|
}
|
|
|
|
function sendInboxMessage(paths, teamName, flags) {
|
|
const to = typeof flags.to === 'string' ? flags.to.trim() : '';
|
|
if (!to) die('Missing --to');
|
|
const text = typeof flags.text === 'string' ? flags.text : '';
|
|
if (!text) die('Missing --text');
|
|
const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
|
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
|
|
|
|
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
|
|
ensureDir(path.dirname(inboxPath));
|
|
|
|
const messageId = crypto.randomUUID
|
|
? crypto.randomUUID()
|
|
: String(Date.now()) + '-' + String(Math.random());
|
|
const payload = {
|
|
from,
|
|
text,
|
|
timestamp: nowIso(),
|
|
read: false,
|
|
summary,
|
|
messageId,
|
|
};
|
|
|
|
const existing = readJson(inboxPath, []);
|
|
const list = Array.isArray(existing) ? existing : [];
|
|
list.push(payload);
|
|
atomicWrite(inboxPath, JSON.stringify(list, null, 2));
|
|
const verify = readJson(inboxPath, []);
|
|
if (!Array.isArray(verify) || !verify.some((m) => m && m.messageId === messageId)) {
|
|
die('Inbox write verification failed');
|
|
}
|
|
return { deliveredToInbox: true, messageId };
|
|
}
|
|
|
|
function reviewApprove(paths, teamName, taskId, flags) {
|
|
setKanbanColumn(paths, teamName, taskId, 'approved');
|
|
const notify = flags.notify === true || flags['notify-owner'] === true;
|
|
if (!notify) return;
|
|
const { task } = readTask(paths, taskId);
|
|
if (!task.owner) return;
|
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
|
|
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
|
|
const text = note
|
|
? 'Task #' + String(taskId) + ' approved.\n\n' + note
|
|
: 'Task #' + String(taskId) + ' approved.';
|
|
sendInboxMessage(paths, teamName, {
|
|
to: task.owner,
|
|
text,
|
|
summary: 'Approved #' + String(taskId),
|
|
from,
|
|
});
|
|
}
|
|
|
|
function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|
const comment = typeof flags.comment === 'string' ? flags.comment.trim() : '';
|
|
const { taskPath, task } = readTask(paths, taskId);
|
|
if (!task.owner) die('No owner found for task ' + String(taskId));
|
|
|
|
clearKanban(paths, teamName, taskId);
|
|
task.status = 'in_progress';
|
|
writeTask(taskPath, task);
|
|
|
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
|
|
const text =
|
|
'Task #' +
|
|
String(taskId) +
|
|
' needs fixes.\n\n' +
|
|
(comment || 'Reviewer requested changes.') +
|
|
'\n\n' +
|
|
'Please fix and mark it as completed when ready.';
|
|
sendInboxMessage(paths, teamName, {
|
|
to: task.owner,
|
|
text,
|
|
summary: 'Fix request for #' + String(taskId),
|
|
from,
|
|
});
|
|
}
|
|
|
|
function printHelp() {
|
|
const inferred = inferTeamNameFromScriptPath();
|
|
const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : '';
|
|
process.stdout.write(
|
|
[
|
|
'teamctl.js v' + String(TOOL_VERSION) + teamHint,
|
|
'',
|
|
'Usage:',
|
|
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
|
|
' node teamctl.js task complete <id> [--team <team>]',
|
|
' node teamctl.js task start <id> [--team <team>]',
|
|
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team <team>]',
|
|
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
|
' node teamctl.js kanban clear <id> [--team <team>]',
|
|
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
|
|
' node teamctl.js review request-changes <id> --comment "..." [--from "member"] [--team <team>]',
|
|
' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team <team>]',
|
|
'',
|
|
'Options:',
|
|
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
|
|
' --claude-dir <path> Override ~/.claude location',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.flags.help || args._.length === 0) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
const domain = args._[0];
|
|
const action = args._[1];
|
|
const rest = args._.slice(2);
|
|
|
|
const teamName = getTeamName(args.flags);
|
|
const paths = getPaths(args.flags, teamName);
|
|
|
|
if (domain === 'task') {
|
|
if (action === 'set-status') {
|
|
const id = rest[0] || args.flags.id;
|
|
const status = rest[1] || args.flags.status;
|
|
if (!id || !status) die('Usage: task set-status <id> <status>');
|
|
setTaskStatus(paths, String(id), status);
|
|
process.stdout.write('OK task #' + String(id) + ' status=' + String(status) + '\n');
|
|
return;
|
|
}
|
|
if (action === 'complete' || action === 'done') {
|
|
const id = rest[0] || args.flags.id;
|
|
if (!id) die('Usage: task complete <id>');
|
|
setTaskStatus(paths, String(id), 'completed');
|
|
process.stdout.write('OK task #' + String(id) + ' status=completed\n');
|
|
return;
|
|
}
|
|
if (action === 'start') {
|
|
const id = rest[0] || args.flags.id;
|
|
if (!id) die('Usage: task start <id>');
|
|
setTaskStatus(paths, String(id), 'in_progress');
|
|
process.stdout.write('OK task #' + String(id) + ' status=in_progress\n');
|
|
return;
|
|
}
|
|
if (action === 'create') {
|
|
const task = createTask(paths, args.flags);
|
|
const notify = args.flags.notify === true || args.flags['notify-owner'] === true;
|
|
if (notify && task.owner) {
|
|
const from =
|
|
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user';
|
|
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
|
|
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
|
|
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
|
|
if (rawDesc && rawDesc !== task.subject) {
|
|
parts.push('\nDescription:\n' + rawDesc);
|
|
}
|
|
const prompt = typeof args.flags.prompt === 'string' ? args.flags.prompt.trim() : '';
|
|
if (prompt) {
|
|
parts.push('\nInstructions:\n' + prompt);
|
|
}
|
|
parts.push(
|
|
'\n${'```'}info_for_agent',
|
|
'Update task status using:',
|
|
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
|
|
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
|
'${'```'}'
|
|
);
|
|
sendInboxMessage(paths, teamName, {
|
|
to: task.owner,
|
|
text: parts.join('\n'),
|
|
summary: 'New task #' + String(task.id) + ' assigned',
|
|
from,
|
|
});
|
|
}
|
|
process.stdout.write(JSON.stringify(task, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (action === 'get') {
|
|
const id = rest[0] || args.flags.id;
|
|
if (!id) die('Usage: task get <id>');
|
|
const { task } = readTask(paths, String(id));
|
|
process.stdout.write(JSON.stringify(task, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (action === 'list') {
|
|
const ids = listTaskIds(paths.tasksDir);
|
|
const tasks = [];
|
|
for (const id of ids) {
|
|
try {
|
|
tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null));
|
|
} catch {}
|
|
}
|
|
process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n');
|
|
return;
|
|
}
|
|
die('Unknown task action: ' + String(action));
|
|
}
|
|
|
|
if (domain === 'kanban') {
|
|
if (action === 'set-column') {
|
|
const id = rest[0] || args.flags.id;
|
|
const column = rest[1] || args.flags.column;
|
|
if (!id || !column) die('Usage: kanban set-column <id> <review|approved>');
|
|
setKanbanColumn(paths, teamName, String(id), column);
|
|
process.stdout.write(
|
|
'OK kanban #' + String(id) + ' column=' + String(column) + '\n'
|
|
);
|
|
return;
|
|
}
|
|
if (action === 'clear' || action === 'remove') {
|
|
const id = rest[0] || args.flags.id;
|
|
if (!id) die('Usage: kanban clear <id>');
|
|
clearKanban(paths, teamName, String(id));
|
|
process.stdout.write('OK kanban #' + String(id) + ' cleared\n');
|
|
return;
|
|
}
|
|
if (action === 'reviewers') {
|
|
const sub = rest[0];
|
|
const name = rest[1];
|
|
const state = readKanbanState(paths, teamName);
|
|
if (sub === 'list') {
|
|
process.stdout.write(JSON.stringify(state.reviewers, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if ((sub === 'add' || sub === 'remove') && (!name || !String(name).trim())) {
|
|
die('Usage: kanban reviewers add|remove <name>');
|
|
}
|
|
const trimmed = String(name || '').trim();
|
|
const before = new Set(state.reviewers);
|
|
if (sub === 'add') before.add(trimmed);
|
|
else if (sub === 'remove') before.delete(trimmed);
|
|
else die('Usage: kanban reviewers list|add|remove ...');
|
|
state.reviewers = [...before];
|
|
writeKanbanState(paths, state);
|
|
process.stdout.write('OK reviewers ' + String(sub) + '\n');
|
|
return;
|
|
}
|
|
die('Unknown kanban action: ' + String(action));
|
|
}
|
|
|
|
if (domain === 'review') {
|
|
if (action === 'approve') {
|
|
const id = rest[0] || args.flags.id;
|
|
if (!id) die('Usage: review approve <id>');
|
|
reviewApprove(paths, teamName, String(id), args.flags);
|
|
process.stdout.write('OK review #' + String(id) + ' approved\n');
|
|
return;
|
|
}
|
|
if (action === 'request-changes') {
|
|
const id = rest[0] || args.flags.id;
|
|
if (!id) die('Usage: review request-changes <id> --comment "..."');
|
|
reviewRequestChanges(paths, teamName, String(id), args.flags);
|
|
process.stdout.write('OK review #' + String(id) + ' requested changes\n');
|
|
return;
|
|
}
|
|
die('Unknown review action: ' + String(action));
|
|
}
|
|
|
|
if (domain === 'message') {
|
|
if (action === 'send') {
|
|
const result = sendInboxMessage(paths, teamName, args.flags);
|
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
return;
|
|
}
|
|
die('Unknown message action: ' + String(action));
|
|
}
|
|
|
|
die('Unknown domain: ' + String(domain));
|
|
}
|
|
|
|
main().catch((err) => {
|
|
die(formatError(err));
|
|
});
|
|
`;
|
|
|
|
return script.trimStart();
|
|
}
|
|
|
|
export class TeamAgentToolsInstaller {
|
|
async ensureInstalled(): Promise<string> {
|
|
const toolsDir = getToolsBasePath();
|
|
const toolPath = path.join(toolsDir, TOOL_FILE_NAME);
|
|
await fs.promises.mkdir(toolsDir, { recursive: true });
|
|
|
|
const desired = buildTeamCtlScript();
|
|
let current: string | null = null;
|
|
try {
|
|
current = await fs.promises.readFile(toolPath, 'utf8');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (current?.includes(`TOOL_VERSION = ${TOOL_VERSION}`)) {
|
|
return toolPath;
|
|
}
|
|
|
|
await atomicWriteAsync(toolPath, desired);
|
|
return toolPath;
|
|
}
|
|
}
|