logwork-cli 0.0.0-stage → 1.1.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.
Files changed (96) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +330 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +104 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/commits.mjs +82 -0
  7. package/commands/delete.mjs +69 -0
  8. package/commands/doctor.mjs +24 -0
  9. package/commands/edit.mjs +88 -0
  10. package/commands/history.mjs +62 -0
  11. package/commands/query.mjs +52 -0
  12. package/commands/quick.mjs +163 -0
  13. package/commands/reconcile.mjs +55 -0
  14. package/commands/release.mjs +77 -0
  15. package/commands/reminder.mjs +172 -0
  16. package/commands/setup.mjs +508 -0
  17. package/commands/skill.mjs +91 -0
  18. package/commands/tui.mjs +50 -0
  19. package/commands/update.mjs +161 -0
  20. package/docs/advanced.md +334 -0
  21. package/docs/mcp-setup.md +368 -0
  22. package/docs/operations-guide.md +587 -0
  23. package/docs/recipes.md +259 -0
  24. package/docs/security.md +151 -0
  25. package/examples/mcp/antigravity.mcp_config.json +8 -0
  26. package/examples/mcp/codex.config.toml +5 -0
  27. package/examples/mcp/copilot.mcp.json +9 -0
  28. package/lib/agent-skill.mjs +16 -0
  29. package/lib/api-auth.mjs +1259 -0
  30. package/lib/api.mjs +1422 -0
  31. package/lib/apply-ledger.mjs +227 -0
  32. package/lib/atomic-file.mjs +24 -0
  33. package/lib/auth-diagnostics.mjs +113 -0
  34. package/lib/auth-errors.mjs +22 -0
  35. package/lib/auth-redaction.mjs +78 -0
  36. package/lib/auth-terminal.mjs +97 -0
  37. package/lib/auth.mjs +323 -0
  38. package/lib/batch-parser.mjs +145 -0
  39. package/lib/batch-workflow.mjs +842 -0
  40. package/lib/commit-workflow.mjs +402 -0
  41. package/lib/config.mjs +191 -0
  42. package/lib/credential-store.mjs +130 -0
  43. package/lib/diagnostics.mjs +307 -0
  44. package/lib/entrypoint.mjs +14 -0
  45. package/lib/file-lock.mjs +44 -0
  46. package/lib/http.mjs +125 -0
  47. package/lib/jira-api.mjs +258 -0
  48. package/lib/jira-auth.mjs +203 -0
  49. package/lib/jira-workflow.mjs +523 -0
  50. package/lib/keychain.mjs +105 -0
  51. package/lib/legacy-migration.mjs +66 -0
  52. package/lib/list-navigation.mjs +19 -0
  53. package/lib/logwork-config.mjs +199 -0
  54. package/lib/logwork-f03-enrich.mjs +41 -0
  55. package/lib/macos.mjs +33 -0
  56. package/lib/manual-apply-state.mjs +35 -0
  57. package/lib/manual-commands.mjs +294 -0
  58. package/lib/manual-drafts.mjs +154 -0
  59. package/lib/manual-ink-app.mjs +1654 -0
  60. package/lib/manual-ink-auth.mjs +349 -0
  61. package/lib/manual-ink-ui.mjs +675 -0
  62. package/lib/manual-input.mjs +21 -0
  63. package/lib/manual-logwork-wizard.mjs +310 -0
  64. package/lib/manual-mcp.mjs +187 -0
  65. package/lib/manual-repl.mjs +1037 -0
  66. package/lib/manual-targets.mjs +78 -0
  67. package/lib/mcp-server.mjs +833 -0
  68. package/lib/os-notification.mjs +105 -0
  69. package/lib/package-info.mjs +17 -0
  70. package/lib/paths.mjs +68 -0
  71. package/lib/project-identity.mjs +66 -0
  72. package/lib/project-mapping-workflow.mjs +309 -0
  73. package/lib/project-resolver.mjs +373 -0
  74. package/lib/query-workflow.mjs +553 -0
  75. package/lib/reconciliation-workflow.mjs +446 -0
  76. package/lib/release-doctor.mjs +448 -0
  77. package/lib/reminder-config.mjs +71 -0
  78. package/lib/reminder-scheduler.mjs +301 -0
  79. package/lib/reminder-service.mjs +118 -0
  80. package/lib/reminder-state.mjs +38 -0
  81. package/lib/reminder-workflow.mjs +299 -0
  82. package/lib/ro-delete-args.mjs +38 -0
  83. package/lib/ro-delete-workflow.mjs +115 -0
  84. package/lib/ro-edit-args.mjs +142 -0
  85. package/lib/ro-edit-workflow.mjs +632 -0
  86. package/lib/ro-entry-reader.mjs +56 -0
  87. package/lib/ro-resubmit-workflow.mjs +134 -0
  88. package/lib/terminal-auth-prompts.mjs +120 -0
  89. package/lib/ui.mjs +133 -0
  90. package/lib/update-service.mjs +350 -0
  91. package/lib/util.mjs +66 -0
  92. package/lib/windows-credential-manager.mjs +260 -0
  93. package/lib/worklog-task-resolver.mjs +113 -0
  94. package/package.json +73 -4
  95. package/setup.sh +22 -0
  96. package/skills/logwork/SKILL.md +43 -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
+ `;