logwork-cli 0.0.0-stage → 1.0.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/LICENSE +11 -0
- package/README.md +287 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +101 -0
- package/commands/auth.mjs +227 -0
- package/commands/delete.mjs +69 -0
- package/commands/doctor.mjs +24 -0
- package/commands/edit.mjs +88 -0
- package/commands/history.mjs +62 -0
- package/commands/query.mjs +52 -0
- package/commands/quick.mjs +163 -0
- package/commands/reconcile.mjs +55 -0
- package/commands/release.mjs +77 -0
- package/commands/reminder.mjs +172 -0
- package/commands/setup.mjs +504 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +347 -0
- package/docs/operations-guide.md +587 -0
- package/docs/recipes.md +259 -0
- package/docs/security.md +151 -0
- package/examples/mcp/antigravity.mcp_config.json +8 -0
- package/examples/mcp/codex.config.toml +5 -0
- package/examples/mcp/copilot.mcp.json +9 -0
- package/lib/api-auth.mjs +1259 -0
- package/lib/api.mjs +1422 -0
- package/lib/apply-ledger.mjs +227 -0
- package/lib/atomic-file.mjs +24 -0
- package/lib/auth-diagnostics.mjs +113 -0
- package/lib/auth-errors.mjs +22 -0
- package/lib/auth-redaction.mjs +78 -0
- package/lib/auth-terminal.mjs +97 -0
- package/lib/auth.mjs +323 -0
- package/lib/batch-parser.mjs +145 -0
- package/lib/batch-workflow.mjs +842 -0
- package/lib/config.mjs +191 -0
- package/lib/credential-store.mjs +130 -0
- package/lib/diagnostics.mjs +307 -0
- package/lib/entrypoint.mjs +14 -0
- package/lib/file-lock.mjs +44 -0
- package/lib/http.mjs +125 -0
- package/lib/jira-api.mjs +258 -0
- package/lib/jira-auth.mjs +203 -0
- package/lib/jira-workflow.mjs +523 -0
- package/lib/keychain.mjs +105 -0
- package/lib/legacy-migration.mjs +66 -0
- package/lib/list-navigation.mjs +19 -0
- package/lib/logwork-config.mjs +199 -0
- package/lib/logwork-f03-enrich.mjs +41 -0
- package/lib/macos.mjs +33 -0
- package/lib/manual-apply-state.mjs +35 -0
- package/lib/manual-commands.mjs +294 -0
- package/lib/manual-drafts.mjs +154 -0
- package/lib/manual-ink-app.mjs +1654 -0
- package/lib/manual-ink-auth.mjs +349 -0
- package/lib/manual-ink-ui.mjs +675 -0
- package/lib/manual-input.mjs +21 -0
- package/lib/manual-logwork-wizard.mjs +310 -0
- package/lib/manual-mcp.mjs +187 -0
- package/lib/manual-repl.mjs +1037 -0
- package/lib/manual-targets.mjs +78 -0
- package/lib/mcp-server.mjs +793 -0
- package/lib/os-notification.mjs +105 -0
- package/lib/package-info.mjs +17 -0
- package/lib/paths.mjs +68 -0
- package/lib/project-identity.mjs +66 -0
- package/lib/project-mapping-workflow.mjs +309 -0
- package/lib/project-resolver.mjs +373 -0
- package/lib/query-workflow.mjs +553 -0
- package/lib/reconciliation-workflow.mjs +446 -0
- package/lib/release-doctor.mjs +448 -0
- package/lib/reminder-config.mjs +71 -0
- package/lib/reminder-scheduler.mjs +301 -0
- package/lib/reminder-service.mjs +118 -0
- package/lib/reminder-state.mjs +38 -0
- package/lib/reminder-workflow.mjs +299 -0
- package/lib/ro-delete-args.mjs +38 -0
- package/lib/ro-delete-workflow.mjs +115 -0
- package/lib/ro-edit-args.mjs +142 -0
- package/lib/ro-edit-workflow.mjs +632 -0
- package/lib/ro-entry-reader.mjs +56 -0
- package/lib/ro-resubmit-workflow.mjs +134 -0
- package/lib/terminal-auth-prompts.mjs +120 -0
- package/lib/ui.mjs +133 -0
- package/lib/update-service.mjs +350 -0
- package/lib/util.mjs +66 -0
- package/lib/windows-credential-manager.mjs +260 -0
- package/lib/worklog-task-resolver.mjs +113 -0
- package/package.json +72 -4
- package/setup.sh +22 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { atomicWriteFile } from './atomic-file.mjs';
|
|
5
|
+
import { redactText } from './auth-redaction.mjs';
|
|
6
|
+
import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
|
|
7
|
+
import { readPackageInfo } from './package-info.mjs';
|
|
8
|
+
import { helperHome, updateStatePath } from './paths.mjs';
|
|
9
|
+
|
|
10
|
+
export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
export const NPM_REGISTRY_ORIGIN = 'https://registry.npmjs.org';
|
|
12
|
+
export const UPDATE_PACKAGE_NAME = 'logwork-cli';
|
|
13
|
+
export const RELEASES_URL = 'https://github.com/malcohelper/logwork-cli/releases';
|
|
14
|
+
|
|
15
|
+
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
16
|
+
|
|
17
|
+
export function parseSemver(value) {
|
|
18
|
+
const text = String(value || '').trim();
|
|
19
|
+
const match = SEMVER_RE.exec(text);
|
|
20
|
+
if (!match) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const prerelease = match[4]
|
|
25
|
+
? match[4].split('.').map((identifier) => (/^\d+$/.test(identifier) ? Number(identifier) : identifier))
|
|
26
|
+
: [];
|
|
27
|
+
if (match[4]?.split('.').some((identifier) => /^0\d+$/.test(identifier))) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
raw: text,
|
|
33
|
+
major: Number(match[1]),
|
|
34
|
+
minor: Number(match[2]),
|
|
35
|
+
patch: Number(match[3]),
|
|
36
|
+
prerelease,
|
|
37
|
+
build: match[5] || ''
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function compareVersions(left, right) {
|
|
42
|
+
const leftVersion = requireSemver(left, 'Current version');
|
|
43
|
+
const rightVersion = requireSemver(right, 'Latest version');
|
|
44
|
+
|
|
45
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
46
|
+
if (leftVersion[key] !== rightVersion[key]) {
|
|
47
|
+
return leftVersion[key] < rightVersion[key] ? -1 : 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!leftVersion.prerelease.length && !rightVersion.prerelease.length) {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (!leftVersion.prerelease.length) {
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
if (!rightVersion.prerelease.length) {
|
|
58
|
+
return -1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const length = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length);
|
|
62
|
+
for (let index = 0; index < length; index += 1) {
|
|
63
|
+
const leftIdentifier = leftVersion.prerelease[index];
|
|
64
|
+
const rightIdentifier = rightVersion.prerelease[index];
|
|
65
|
+
if (leftIdentifier === undefined) {
|
|
66
|
+
return -1;
|
|
67
|
+
}
|
|
68
|
+
if (rightIdentifier === undefined) {
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
if (leftIdentifier === rightIdentifier) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (typeof leftIdentifier === 'number' && typeof rightIdentifier !== 'number') {
|
|
75
|
+
return -1;
|
|
76
|
+
}
|
|
77
|
+
if (typeof leftIdentifier !== 'number' && typeof rightIdentifier === 'number') {
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
return leftIdentifier < rightIdentifier ? -1 : 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isNewerVersion(latestVersion, currentVersion) {
|
|
87
|
+
return compareVersions(latestVersion, currentVersion) > 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function checkForUpdates({
|
|
91
|
+
force = false,
|
|
92
|
+
allowStaleOnError = true,
|
|
93
|
+
packageInfo = readPackageInfo(),
|
|
94
|
+
cachePath = updateStatePath(),
|
|
95
|
+
cacheTtlMs = UPDATE_CACHE_TTL_MS,
|
|
96
|
+
fetchImpl = fetch,
|
|
97
|
+
now = Date.now
|
|
98
|
+
} = {}) {
|
|
99
|
+
const currentVersion = requireSemver(packageInfo.version, 'Installed package version').raw;
|
|
100
|
+
const packageName = String(packageInfo.name || '').trim();
|
|
101
|
+
if (packageName !== UPDATE_PACKAGE_NAME) {
|
|
102
|
+
throw new Error(`Update checks are restricted to ${UPDATE_PACKAGE_NAME}.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const currentTime = Number(now());
|
|
106
|
+
const cached = await readUpdateState(cachePath);
|
|
107
|
+
if (!force && isFreshCache(cached, packageName, currentTime, cacheTtlMs)) {
|
|
108
|
+
return buildUpdateResult({
|
|
109
|
+
packageName,
|
|
110
|
+
currentVersion,
|
|
111
|
+
latestVersion: cached.latestVersion,
|
|
112
|
+
checkedAt: cached.checkedAt,
|
|
113
|
+
source: 'cache'
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const latest = await fetchLatestPackage({ packageName, fetchImpl });
|
|
119
|
+
const checkedAt = new Date(currentTime).toISOString();
|
|
120
|
+
const state = {
|
|
121
|
+
packageName,
|
|
122
|
+
latestVersion: latest.version,
|
|
123
|
+
checkedAt
|
|
124
|
+
};
|
|
125
|
+
await atomicWriteFile(cachePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
126
|
+
return buildUpdateResult({
|
|
127
|
+
packageName,
|
|
128
|
+
currentVersion,
|
|
129
|
+
latestVersion: latest.version,
|
|
130
|
+
checkedAt,
|
|
131
|
+
source: 'registry'
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (allowStaleOnError && isValidCache(cached, packageName)) {
|
|
135
|
+
return {
|
|
136
|
+
...buildUpdateResult({
|
|
137
|
+
packageName,
|
|
138
|
+
currentVersion,
|
|
139
|
+
latestVersion: cached.latestVersion,
|
|
140
|
+
checkedAt: cached.checkedAt,
|
|
141
|
+
source: 'stale_cache'
|
|
142
|
+
}),
|
|
143
|
+
warning: `Registry check failed; showing the last cached result. ${redactText(error.message)}`
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function installUpdate({
|
|
151
|
+
version,
|
|
152
|
+
confirm,
|
|
153
|
+
checkForUpdatesFn = checkForUpdates,
|
|
154
|
+
runCommand = runUpdateCommand,
|
|
155
|
+
readInstalledVersion = readRuntimeVersion,
|
|
156
|
+
packageInfo = readPackageInfo(),
|
|
157
|
+
stdio = 'pipe'
|
|
158
|
+
} = {}) {
|
|
159
|
+
if (confirm !== true) {
|
|
160
|
+
throw new Error('Update installation requires confirm: true.');
|
|
161
|
+
}
|
|
162
|
+
if (packageInfo.name !== UPDATE_PACKAGE_NAME) {
|
|
163
|
+
throw new Error(`Update installation is restricted to ${UPDATE_PACKAGE_NAME}.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const requestedVersion = version === undefined
|
|
167
|
+
? null
|
|
168
|
+
: requireSemver(version, 'Requested update version').raw;
|
|
169
|
+
const update = await checkForUpdatesFn({
|
|
170
|
+
force: true,
|
|
171
|
+
allowStaleOnError: false,
|
|
172
|
+
packageInfo
|
|
173
|
+
});
|
|
174
|
+
const targetVersion = requestedVersion || update.latestVersion;
|
|
175
|
+
|
|
176
|
+
if (targetVersion !== update.latestVersion) {
|
|
177
|
+
throw new Error(`Only the npm latest version can be installed. Requested ${targetVersion}; latest is ${update.latestVersion}.`);
|
|
178
|
+
}
|
|
179
|
+
if (!isNewerVersion(targetVersion, packageInfo.version)) {
|
|
180
|
+
return {
|
|
181
|
+
status: 'up_to_date',
|
|
182
|
+
previousVersion: packageInfo.version,
|
|
183
|
+
installedVersion: packageInfo.version,
|
|
184
|
+
latestVersion: update.latestVersion,
|
|
185
|
+
restartRequired: false,
|
|
186
|
+
summary: `logwork-cli ${packageInfo.version} is already up to date.`
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const executable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
191
|
+
const args = [
|
|
192
|
+
'exec',
|
|
193
|
+
'--yes',
|
|
194
|
+
`--registry=${NPM_REGISTRY_ORIGIN}`,
|
|
195
|
+
`--package=${packageInfo.name}@${targetVersion}`,
|
|
196
|
+
'--',
|
|
197
|
+
'logwork',
|
|
198
|
+
'setup',
|
|
199
|
+
'--no-login'
|
|
200
|
+
];
|
|
201
|
+
const commandResult = await runCommand(executable, args, { stdio });
|
|
202
|
+
if (commandResult.code !== 0) {
|
|
203
|
+
const detail = redactedExcerpt(commandResult.stderr || commandResult.stdout || 'npm update command failed.');
|
|
204
|
+
throw new Error(`Update installation failed with exit code ${commandResult.code}. ${detail}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const installedVersion = await readInstalledVersion();
|
|
208
|
+
if (installedVersion !== targetVersion) {
|
|
209
|
+
throw new Error(`Update verification failed: expected ${targetVersion}, found ${installedVersion || 'no installed version'}.`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
status: 'updated',
|
|
214
|
+
previousVersion: packageInfo.version,
|
|
215
|
+
installedVersion,
|
|
216
|
+
latestVersion: update.latestVersion,
|
|
217
|
+
restartRequired: true,
|
|
218
|
+
command: `${executable} exec --yes --registry=${NPM_REGISTRY_ORIGIN} --package=${packageInfo.name}@${targetVersion} -- logwork setup --no-login`,
|
|
219
|
+
summary: `Updated logwork-cli from ${packageInfo.version} to ${installedVersion}. Restart the terminal session and reconnect MCP clients.`
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function formatUpdateNotice(result) {
|
|
224
|
+
if (!result?.updateAvailable) {
|
|
225
|
+
return '';
|
|
226
|
+
}
|
|
227
|
+
return `Update available: ${result.currentVersion} -> ${result.latestVersion}. Run \`logwork update install\`.`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function fetchLatestPackage({ packageName, fetchImpl }) {
|
|
231
|
+
const url = `${NPM_REGISTRY_ORIGIN}/${encodeURIComponent(packageName)}/latest`;
|
|
232
|
+
const response = await fetchWithPolicy(url, {
|
|
233
|
+
fetchImpl,
|
|
234
|
+
timeoutMs: 3_000,
|
|
235
|
+
retries: 1,
|
|
236
|
+
retryDelayMs: 150,
|
|
237
|
+
idempotent: true,
|
|
238
|
+
headers: {
|
|
239
|
+
accept: 'application/json'
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
const body = await response.text().catch(() => '');
|
|
245
|
+
throw new Error(`npm registry returned HTTP ${response.status}. ${redactedExcerpt(body, 200)}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const body = await response.json();
|
|
249
|
+
if (body?.name && body.name !== packageName) {
|
|
250
|
+
throw new Error('npm registry returned metadata for an unexpected package.');
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
version: requireSemver(body?.version, 'npm latest version').raw
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildUpdateResult({ packageName, currentVersion, latestVersion, checkedAt, source }) {
|
|
258
|
+
const validLatest = requireSemver(latestVersion, 'Cached latest version').raw;
|
|
259
|
+
const updateAvailable = isNewerVersion(validLatest, currentVersion);
|
|
260
|
+
return {
|
|
261
|
+
status: updateAvailable ? 'update_available' : 'up_to_date',
|
|
262
|
+
packageName,
|
|
263
|
+
currentVersion,
|
|
264
|
+
latestVersion: validLatest,
|
|
265
|
+
updateAvailable,
|
|
266
|
+
checkedAt,
|
|
267
|
+
source,
|
|
268
|
+
releaseUrl: `${RELEASES_URL}/tag/v${validLatest}`,
|
|
269
|
+
summary: updateAvailable
|
|
270
|
+
? `logwork-cli ${validLatest} is available; current version is ${currentVersion}.`
|
|
271
|
+
: `logwork-cli ${currentVersion} is up to date (npm latest: ${validLatest}).`
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function readUpdateState(path) {
|
|
276
|
+
try {
|
|
277
|
+
const value = JSON.parse(await fs.readFile(path, 'utf8'));
|
|
278
|
+
return value && typeof value === 'object' ? value : null;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isFreshCache(cached, packageName, currentTime, cacheTtlMs) {
|
|
288
|
+
if (!isValidCache(cached, packageName)) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
const checkedAt = Date.parse(cached.checkedAt);
|
|
292
|
+
return Number.isFinite(checkedAt) && currentTime - checkedAt >= 0 && currentTime - checkedAt < cacheTtlMs;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isValidCache(cached, packageName) {
|
|
296
|
+
return Boolean(
|
|
297
|
+
cached &&
|
|
298
|
+
cached.packageName === packageName &&
|
|
299
|
+
parseSemver(cached.latestVersion) &&
|
|
300
|
+
Number.isFinite(Date.parse(cached.checkedAt))
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function requireSemver(value, label) {
|
|
305
|
+
const parsed = parseSemver(value);
|
|
306
|
+
if (!parsed) {
|
|
307
|
+
throw new Error(`${label} must be an exact SemVer value such as 1.2.3.`);
|
|
308
|
+
}
|
|
309
|
+
return parsed;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function runUpdateCommand(executable, args, { stdio = 'pipe' } = {}) {
|
|
313
|
+
return new Promise((resolveValue, reject) => {
|
|
314
|
+
const child = spawn(executable, args, {
|
|
315
|
+
cwd: helperHome(),
|
|
316
|
+
env: process.env,
|
|
317
|
+
stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe']
|
|
318
|
+
});
|
|
319
|
+
let stdout = '';
|
|
320
|
+
let stderr = '';
|
|
321
|
+
|
|
322
|
+
if (stdio !== 'inherit') {
|
|
323
|
+
child.stdout?.setEncoding('utf8');
|
|
324
|
+
child.stderr?.setEncoding('utf8');
|
|
325
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk; });
|
|
326
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk; });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
child.on('error', reject);
|
|
330
|
+
child.on('close', (code, signal) => {
|
|
331
|
+
resolveValue({
|
|
332
|
+
code: signal ? 1 : (code ?? 1),
|
|
333
|
+
stdout,
|
|
334
|
+
stderr
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function readRuntimeVersion() {
|
|
341
|
+
try {
|
|
342
|
+
const packageJson = JSON.parse(await fs.readFile(join(helperHome(), 'package.json'), 'utf8'));
|
|
343
|
+
return parseSemver(packageJson?.version)?.raw || null;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
package/lib/util.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export function sleep(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function nowIso() {
|
|
6
|
+
return new Date().toISOString();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function safeJsonParse(text, fallback = null) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(text);
|
|
12
|
+
} catch {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function assertNonEmptyString(value, name) {
|
|
18
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
19
|
+
throw new Error(`${name} must be a non-empty string.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function todayLocalDateISO(date = new Date()) {
|
|
26
|
+
const year = date.getFullYear();
|
|
27
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
28
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
29
|
+
return `${year}-${month}-${day}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function toApiLogDate(localDateISO) {
|
|
33
|
+
return `${localDateISO}T00:00:00.000Z`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function addLocalDays(localDateISO, days) {
|
|
37
|
+
const [year, month, day] = localDateISO.split('-').map(Number);
|
|
38
|
+
const date = new Date(year, month - 1, day + days);
|
|
39
|
+
return todayLocalDateISO(date);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function mapLimit(items, limit, mapper) {
|
|
43
|
+
if (!Array.isArray(items)) {
|
|
44
|
+
throw new Error('items must be an array.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const concurrency = Math.max(1, Number(limit) || 1);
|
|
48
|
+
const results = new Array(items.length);
|
|
49
|
+
let nextIndex = 0;
|
|
50
|
+
|
|
51
|
+
async function worker() {
|
|
52
|
+
while (nextIndex < items.length) {
|
|
53
|
+
const currentIndex = nextIndex;
|
|
54
|
+
nextIndex += 1;
|
|
55
|
+
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const workerCount = Math.min(concurrency, items.length);
|
|
60
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
61
|
+
return results;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isAbortSignal(value) {
|
|
65
|
+
return Boolean(value && typeof value === 'object' && value.aborted === true);
|
|
66
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export const WINDOWS_CREDENTIAL_PERSIST_LOCAL_MACHINE = 2;
|
|
4
|
+
|
|
5
|
+
export function createWindowsCredentialTokenStorage({
|
|
6
|
+
service = 'logwork-cli',
|
|
7
|
+
account = 'resourceoptimiser',
|
|
8
|
+
run = runPowerShellCredentialCommand
|
|
9
|
+
} = {}) {
|
|
10
|
+
return createWindowsCredentialValueStorage({ service, account, run });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createWindowsCredentialEmailStorage({
|
|
14
|
+
service = 'logwork-cli',
|
|
15
|
+
account = 'resourceoptimiser-email',
|
|
16
|
+
run = runPowerShellCredentialCommand
|
|
17
|
+
} = {}) {
|
|
18
|
+
return createWindowsCredentialValueStorage({ service, account, run });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createWindowsCredentialValueStorage({
|
|
22
|
+
service = 'logwork-cli',
|
|
23
|
+
account = 'resourceoptimiser',
|
|
24
|
+
run = runPowerShellCredentialCommand
|
|
25
|
+
} = {}) {
|
|
26
|
+
const target = windowsCredentialTarget(service, account);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
async get() {
|
|
30
|
+
const result = await run({
|
|
31
|
+
action: 'get',
|
|
32
|
+
target,
|
|
33
|
+
username: account
|
|
34
|
+
});
|
|
35
|
+
return result?.found ? String(result.value || '') : null;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async set(value) {
|
|
39
|
+
await run({
|
|
40
|
+
action: 'set',
|
|
41
|
+
target,
|
|
42
|
+
username: account,
|
|
43
|
+
value
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async delete() {
|
|
48
|
+
const result = await run({
|
|
49
|
+
action: 'delete',
|
|
50
|
+
target,
|
|
51
|
+
username: account
|
|
52
|
+
});
|
|
53
|
+
return Boolean(result?.deleted);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function windowsCredentialTarget(service, account) {
|
|
59
|
+
return `${service}:${account}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function runPowerShellCredentialCommand(request, {
|
|
63
|
+
command = 'powershell.exe',
|
|
64
|
+
spawnFn = spawn
|
|
65
|
+
} = {}) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const child = spawnFn(command, [
|
|
68
|
+
'-NoProfile',
|
|
69
|
+
'-NonInteractive',
|
|
70
|
+
'-ExecutionPolicy',
|
|
71
|
+
'Bypass',
|
|
72
|
+
'-Command',
|
|
73
|
+
WINDOWS_CREDENTIAL_SCRIPT
|
|
74
|
+
], {
|
|
75
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
76
|
+
windowsHide: true
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let stdout = '';
|
|
80
|
+
let stderr = '';
|
|
81
|
+
|
|
82
|
+
child.stdout.setEncoding('utf8');
|
|
83
|
+
child.stderr.setEncoding('utf8');
|
|
84
|
+
child.stdout.on('data', (chunk) => {
|
|
85
|
+
stdout += chunk;
|
|
86
|
+
});
|
|
87
|
+
child.stderr.on('data', (chunk) => {
|
|
88
|
+
stderr += chunk;
|
|
89
|
+
});
|
|
90
|
+
child.on('error', reject);
|
|
91
|
+
child.on('close', (code) => {
|
|
92
|
+
if (code !== 0) {
|
|
93
|
+
reject(new Error(`Windows Credential Manager command failed with exit code ${code}: ${stderr.trim() || 'no stderr'}`));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
resolve(stdout.trim() ? JSON.parse(stdout) : {});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
reject(new Error(`Windows Credential Manager command returned invalid JSON: ${error.message}`));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
child.stdin.end(JSON.stringify(request));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const WINDOWS_CREDENTIAL_SCRIPT = String.raw`
|
|
109
|
+
$ErrorActionPreference = 'Stop'
|
|
110
|
+
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
111
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
112
|
+
|
|
113
|
+
Add-Type -TypeDefinition @'
|
|
114
|
+
using System;
|
|
115
|
+
using System.ComponentModel;
|
|
116
|
+
using System.Runtime.InteropServices;
|
|
117
|
+
using System.Text;
|
|
118
|
+
|
|
119
|
+
public static class LogworkCredentialManager
|
|
120
|
+
{
|
|
121
|
+
private const UInt32 CRED_TYPE_GENERIC = 1;
|
|
122
|
+
private const UInt32 CRED_PERSIST_LOCAL_MACHINE = 2;
|
|
123
|
+
private const Int32 ERROR_NOT_FOUND = 1168;
|
|
124
|
+
|
|
125
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
126
|
+
private struct CREDENTIAL
|
|
127
|
+
{
|
|
128
|
+
public UInt32 Flags;
|
|
129
|
+
public UInt32 Type;
|
|
130
|
+
public string TargetName;
|
|
131
|
+
public string Comment;
|
|
132
|
+
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
133
|
+
public UInt32 CredentialBlobSize;
|
|
134
|
+
public IntPtr CredentialBlob;
|
|
135
|
+
public UInt32 Persist;
|
|
136
|
+
public UInt32 AttributeCount;
|
|
137
|
+
public IntPtr Attributes;
|
|
138
|
+
public string TargetAlias;
|
|
139
|
+
public string UserName;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
143
|
+
private static extern bool CredWrite(ref CREDENTIAL credential, UInt32 flags);
|
|
144
|
+
|
|
145
|
+
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
146
|
+
private static extern bool CredRead(string target, UInt32 type, UInt32 reservedFlag, out IntPtr credentialPtr);
|
|
147
|
+
|
|
148
|
+
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
149
|
+
private static extern bool CredDelete(string target, UInt32 type, UInt32 flags);
|
|
150
|
+
|
|
151
|
+
[DllImport("advapi32.dll", SetLastError = true)]
|
|
152
|
+
private static extern void CredFree(IntPtr credentialPtr);
|
|
153
|
+
|
|
154
|
+
public static void Write(string target, string userName, string secret)
|
|
155
|
+
{
|
|
156
|
+
byte[] secretBytes = Encoding.Unicode.GetBytes(secret ?? "");
|
|
157
|
+
if (secretBytes.Length > 5120)
|
|
158
|
+
{
|
|
159
|
+
throw new InvalidOperationException("Credential value is too large for Windows Credential Manager.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
IntPtr secretPtr = Marshal.AllocCoTaskMem(secretBytes.Length);
|
|
163
|
+
try
|
|
164
|
+
{
|
|
165
|
+
Marshal.Copy(secretBytes, 0, secretPtr, secretBytes.Length);
|
|
166
|
+
CREDENTIAL credential = new CREDENTIAL();
|
|
167
|
+
credential.Type = CRED_TYPE_GENERIC;
|
|
168
|
+
credential.TargetName = target;
|
|
169
|
+
credential.UserName = userName;
|
|
170
|
+
credential.CredentialBlob = secretPtr;
|
|
171
|
+
credential.CredentialBlobSize = (UInt32)secretBytes.Length;
|
|
172
|
+
credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
|
|
173
|
+
|
|
174
|
+
if (!CredWrite(ref credential, 0))
|
|
175
|
+
{
|
|
176
|
+
throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
finally
|
|
180
|
+
{
|
|
181
|
+
Marshal.FreeCoTaskMem(secretPtr);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public static string Read(string target)
|
|
186
|
+
{
|
|
187
|
+
IntPtr credentialPtr;
|
|
188
|
+
if (!CredRead(target, CRED_TYPE_GENERIC, 0, out credentialPtr))
|
|
189
|
+
{
|
|
190
|
+
int error = Marshal.GetLastWin32Error();
|
|
191
|
+
if (error == ERROR_NOT_FOUND)
|
|
192
|
+
{
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
throw new Win32Exception(error);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try
|
|
199
|
+
{
|
|
200
|
+
CREDENTIAL credential = (CREDENTIAL)Marshal.PtrToStructure(credentialPtr, typeof(CREDENTIAL));
|
|
201
|
+
if (credential.CredentialBlobSize == 0)
|
|
202
|
+
{
|
|
203
|
+
return "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
byte[] secretBytes = new byte[credential.CredentialBlobSize];
|
|
207
|
+
Marshal.Copy(credential.CredentialBlob, secretBytes, 0, secretBytes.Length);
|
|
208
|
+
return Encoding.Unicode.GetString(secretBytes);
|
|
209
|
+
}
|
|
210
|
+
finally
|
|
211
|
+
{
|
|
212
|
+
CredFree(credentialPtr);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public static bool Delete(string target)
|
|
217
|
+
{
|
|
218
|
+
if (CredDelete(target, CRED_TYPE_GENERIC, 0))
|
|
219
|
+
{
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
int error = Marshal.GetLastWin32Error();
|
|
224
|
+
if (error == ERROR_NOT_FOUND)
|
|
225
|
+
{
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
throw new Win32Exception(error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
'@
|
|
232
|
+
|
|
233
|
+
$inputJson = [Console]::In.ReadToEnd()
|
|
234
|
+
$request = $inputJson | ConvertFrom-Json
|
|
235
|
+
|
|
236
|
+
switch ($request.action) {
|
|
237
|
+
'get' {
|
|
238
|
+
$value = [LogworkCredentialManager]::Read([string]$request.target)
|
|
239
|
+
if ($null -eq $value) {
|
|
240
|
+
@{ ok = $true; found = $false } | ConvertTo-Json -Compress
|
|
241
|
+
} else {
|
|
242
|
+
@{ ok = $true; found = $true; value = $value } | ConvertTo-Json -Compress
|
|
243
|
+
}
|
|
244
|
+
break
|
|
245
|
+
}
|
|
246
|
+
'set' {
|
|
247
|
+
[LogworkCredentialManager]::Write([string]$request.target, [string]$request.username, [string]$request.value)
|
|
248
|
+
@{ ok = $true } | ConvertTo-Json -Compress
|
|
249
|
+
break
|
|
250
|
+
}
|
|
251
|
+
'delete' {
|
|
252
|
+
$deleted = [LogworkCredentialManager]::Delete([string]$request.target)
|
|
253
|
+
@{ ok = $true; deleted = $deleted } | ConvertTo-Json -Compress
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
default {
|
|
257
|
+
throw "Unknown credential action: $($request.action)"
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
`;
|