@jmoyers/harness 0.1.9 → 0.1.11

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 (54) hide show
  1. package/README.md +36 -155
  2. package/package.json +3 -1
  3. package/packages/harness-ai/src/anthropic-client.ts +99 -0
  4. package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
  5. package/packages/harness-ai/src/anthropic-provider.ts +82 -0
  6. package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
  7. package/packages/harness-ai/src/index.ts +36 -0
  8. package/packages/harness-ai/src/json-parse.ts +66 -0
  9. package/packages/harness-ai/src/sse.ts +80 -0
  10. package/packages/harness-ai/src/stream-object.ts +96 -0
  11. package/packages/harness-ai/src/stream-text.ts +1340 -0
  12. package/packages/harness-ai/src/types.ts +330 -0
  13. package/packages/harness-ai/src/ui-stream.ts +217 -0
  14. package/scripts/codex-live-mux-runtime.ts +265 -14
  15. package/scripts/control-plane-daemon.ts +33 -5
  16. package/scripts/harness.ts +579 -134
  17. package/src/cli/default-gateway-pointer.ts +193 -0
  18. package/src/cli/gateway-record.ts +16 -1
  19. package/src/config/config-core.ts +13 -2
  20. package/src/config/harness-paths.ts +4 -7
  21. package/src/config/harness-runtime-migration.ts +142 -19
  22. package/src/config/secrets-core.ts +92 -4
  23. package/src/control-plane/prompt/thread-title-namer.ts +316 -0
  24. package/src/control-plane/stream-command-parser.ts +12 -0
  25. package/src/control-plane/stream-protocol.ts +6 -0
  26. package/src/control-plane/stream-server-background.ts +18 -2
  27. package/src/control-plane/stream-server-command.ts +14 -0
  28. package/src/control-plane/stream-server.ts +460 -28
  29. package/src/domain/conversations.ts +11 -7
  30. package/src/domain/workspace.ts +9 -0
  31. package/src/mux/input-shortcuts.ts +38 -1
  32. package/src/mux/live-mux/git-parsing.ts +40 -0
  33. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  34. package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
  35. package/src/mux/live-mux/modal-input-reducers.ts +34 -1
  36. package/src/mux/live-mux/modal-overlays.ts +45 -0
  37. package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
  38. package/src/mux/render-frame.ts +1 -1
  39. package/src/mux/task-screen-keybindings.ts +29 -1
  40. package/src/services/control-plane.ts +22 -0
  41. package/src/services/runtime-control-actions.ts +69 -0
  42. package/src/services/runtime-conversation-activation.ts +25 -0
  43. package/src/services/runtime-conversation-starter.ts +31 -7
  44. package/src/services/runtime-input-router.ts +6 -0
  45. package/src/services/runtime-modal-input.ts +18 -0
  46. package/src/services/runtime-navigation-input.ts +4 -0
  47. package/src/services/runtime-rail-input.ts +5 -0
  48. package/src/services/runtime-repository-actions.ts +2 -0
  49. package/src/services/runtime-workspace-actions.ts +5 -0
  50. package/src/store/control-plane-store.ts +36 -0
  51. package/src/store/event-store.ts +36 -0
  52. package/src/ui/global-shortcut-input.ts +2 -0
  53. package/src/ui/input.ts +31 -0
  54. package/src/ui/modals/manager.ts +26 -0
@@ -0,0 +1,193 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { resolveHarnessConfigDirectory } from '../config/config-core.ts';
4
+ import type { GatewayRecord } from './gateway-record.ts';
5
+
6
+ const DEFAULT_GATEWAY_POINTER_VERSION = 1;
7
+ const DEFAULT_GATEWAY_RECORD_PATH_PATTERN = /[\\/]gateway\.json$/u;
8
+ const NAMED_SESSION_GATEWAY_RECORD_PATH_PATTERN = /[\\/]sessions[\\/][^\\/]+[\\/]gateway\.json$/u;
9
+
10
+ const DEFAULT_GATEWAY_POINTER_FILE_NAME = 'default-gateway.json';
11
+
12
+ interface DefaultGatewayPointerRecord {
13
+ readonly version: number;
14
+ readonly workspaceRoot: string;
15
+ readonly workspaceRuntimeRoot: string;
16
+ readonly gatewayRecordPath: string;
17
+ readonly gatewayLogPath: string;
18
+ readonly stateDbPath: string;
19
+ readonly pid: number;
20
+ readonly startedAt: string;
21
+ readonly updatedAt: string;
22
+ readonly gatewayRunId?: string;
23
+ }
24
+
25
+ function asRecord(value: unknown): Record<string, unknown> | null {
26
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
27
+ return null;
28
+ }
29
+ return value as Record<string, unknown>;
30
+ }
31
+
32
+ function readNonEmptyString(value: unknown): string | null {
33
+ if (typeof value !== 'string') {
34
+ return null;
35
+ }
36
+ const trimmed = value.trim();
37
+ return trimmed.length > 0 ? trimmed : null;
38
+ }
39
+
40
+ function readPositiveInt(value: unknown): number | null {
41
+ if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
42
+ return null;
43
+ }
44
+ return value > 0 ? value : null;
45
+ }
46
+
47
+ function isDefaultGatewayRecordPath(recordPath: string): boolean {
48
+ const normalizedPath = resolve(recordPath);
49
+ return (
50
+ DEFAULT_GATEWAY_RECORD_PATH_PATTERN.test(normalizedPath) &&
51
+ !NAMED_SESSION_GATEWAY_RECORD_PATH_PATTERN.test(normalizedPath)
52
+ );
53
+ }
54
+
55
+ export function resolveDefaultGatewayPointerPath(
56
+ invocationDirectory: string,
57
+ env: NodeJS.ProcessEnv = process.env,
58
+ ): string {
59
+ return resolve(
60
+ resolveHarnessConfigDirectory(invocationDirectory, env),
61
+ DEFAULT_GATEWAY_POINTER_FILE_NAME,
62
+ );
63
+ }
64
+
65
+ export function parseDefaultGatewayPointerText(text: string): DefaultGatewayPointerRecord | null {
66
+ let parsed: unknown;
67
+ try {
68
+ parsed = JSON.parse(text);
69
+ } catch {
70
+ return null;
71
+ }
72
+ const record = asRecord(parsed);
73
+ if (record === null) {
74
+ return null;
75
+ }
76
+ if (record['version'] !== DEFAULT_GATEWAY_POINTER_VERSION) {
77
+ return null;
78
+ }
79
+ const workspaceRoot = readNonEmptyString(record['workspaceRoot']);
80
+ const workspaceRuntimeRoot = readNonEmptyString(record['workspaceRuntimeRoot']);
81
+ const gatewayRecordPath = readNonEmptyString(record['gatewayRecordPath']);
82
+ const gatewayLogPath = readNonEmptyString(record['gatewayLogPath']);
83
+ const stateDbPath = readNonEmptyString(record['stateDbPath']);
84
+ const startedAt = readNonEmptyString(record['startedAt']);
85
+ const updatedAt = readNonEmptyString(record['updatedAt']);
86
+ const pid = readPositiveInt(record['pid']);
87
+ const gatewayRunIdRaw = record['gatewayRunId'];
88
+ const gatewayRunId =
89
+ gatewayRunIdRaw === undefined ? undefined : readNonEmptyString(gatewayRunIdRaw);
90
+
91
+ if (
92
+ workspaceRoot === null ||
93
+ workspaceRuntimeRoot === null ||
94
+ gatewayRecordPath === null ||
95
+ gatewayLogPath === null ||
96
+ stateDbPath === null ||
97
+ startedAt === null ||
98
+ updatedAt === null ||
99
+ pid === null ||
100
+ (gatewayRunIdRaw !== undefined && gatewayRunId === null)
101
+ ) {
102
+ return null;
103
+ }
104
+ const parsedGatewayRunId = gatewayRunId === null ? undefined : gatewayRunId;
105
+
106
+ return {
107
+ version: DEFAULT_GATEWAY_POINTER_VERSION,
108
+ workspaceRoot,
109
+ workspaceRuntimeRoot,
110
+ gatewayRecordPath,
111
+ gatewayLogPath,
112
+ stateDbPath,
113
+ pid,
114
+ startedAt,
115
+ updatedAt,
116
+ ...(parsedGatewayRunId === undefined ? {} : { gatewayRunId: parsedGatewayRunId }),
117
+ };
118
+ }
119
+
120
+ export function readDefaultGatewayPointer(
121
+ invocationDirectory: string,
122
+ env: NodeJS.ProcessEnv = process.env,
123
+ ): DefaultGatewayPointerRecord | null {
124
+ const pointerPath = resolveDefaultGatewayPointerPath(invocationDirectory, env);
125
+ if (!existsSync(pointerPath)) {
126
+ return null;
127
+ }
128
+ try {
129
+ return parseDefaultGatewayPointerText(readFileSync(pointerPath, 'utf8'));
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ export function writeDefaultGatewayPointerFromGatewayRecord(
136
+ recordPath: string,
137
+ record: GatewayRecord,
138
+ env: NodeJS.ProcessEnv = process.env,
139
+ ): void {
140
+ if (!isDefaultGatewayRecordPath(recordPath)) {
141
+ return;
142
+ }
143
+ const normalizedRecordPath = resolve(recordPath);
144
+ const pointerPath = resolveDefaultGatewayPointerPath(record.workspaceRoot, env);
145
+ const payload: DefaultGatewayPointerRecord = {
146
+ version: DEFAULT_GATEWAY_POINTER_VERSION,
147
+ workspaceRoot: record.workspaceRoot,
148
+ workspaceRuntimeRoot: dirname(normalizedRecordPath),
149
+ gatewayRecordPath: normalizedRecordPath,
150
+ gatewayLogPath: resolve(dirname(normalizedRecordPath), 'gateway.log'),
151
+ stateDbPath: record.stateDbPath,
152
+ pid: record.pid,
153
+ startedAt: record.startedAt,
154
+ updatedAt: new Date().toISOString(),
155
+ ...(record.gatewayRunId == null ? {} : { gatewayRunId: record.gatewayRunId }),
156
+ };
157
+ mkdirSync(dirname(pointerPath), { recursive: true });
158
+ writeFileSync(pointerPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
159
+ }
160
+
161
+ export function clearDefaultGatewayPointerForRecordPath(
162
+ recordPath: string,
163
+ invocationDirectory: string = process.cwd(),
164
+ env: NodeJS.ProcessEnv = process.env,
165
+ ): void {
166
+ if (!isDefaultGatewayRecordPath(recordPath)) {
167
+ return;
168
+ }
169
+ const pointerPath = resolveDefaultGatewayPointerPath(invocationDirectory, env);
170
+ if (!existsSync(pointerPath)) {
171
+ return;
172
+ }
173
+ let pointer: DefaultGatewayPointerRecord | null = null;
174
+ try {
175
+ pointer = parseDefaultGatewayPointerText(readFileSync(pointerPath, 'utf8'));
176
+ } catch {
177
+ pointer = null;
178
+ }
179
+ if (pointer === null) {
180
+ return;
181
+ }
182
+ if (resolve(pointer.gatewayRecordPath) !== resolve(recordPath)) {
183
+ return;
184
+ }
185
+ try {
186
+ unlinkSync(pointerPath);
187
+ } catch (error: unknown) {
188
+ const code = (error as NodeJS.ErrnoException).code;
189
+ if (code !== 'ENOENT') {
190
+ throw error;
191
+ }
192
+ }
193
+ }
@@ -6,6 +6,7 @@ export const DEFAULT_GATEWAY_PORT = 7777;
6
6
  export const DEFAULT_GATEWAY_DB_PATH = '.harness/control-plane.sqlite';
7
7
  export const DEFAULT_GATEWAY_RECORD_PATH = '.harness/gateway.json';
8
8
  export const DEFAULT_GATEWAY_LOG_PATH = '.harness/gateway.log';
9
+ export const DEFAULT_GATEWAY_LOCK_PATH = '.harness/gateway.lock';
9
10
 
10
11
  export interface GatewayRecord {
11
12
  readonly version: number;
@@ -16,6 +17,7 @@ export interface GatewayRecord {
16
17
  readonly stateDbPath: string;
17
18
  readonly startedAt: string;
18
19
  readonly workspaceRoot: string;
20
+ readonly gatewayRunId?: string;
19
21
  }
20
22
 
21
23
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -74,6 +76,13 @@ export function resolveGatewayLogPath(
74
76
  return resolveHarnessRuntimePath(workspaceRoot, DEFAULT_GATEWAY_LOG_PATH, env);
75
77
  }
76
78
 
79
+ export function resolveGatewayLockPath(
80
+ workspaceRoot: string,
81
+ env: NodeJS.ProcessEnv = process.env,
82
+ ): string {
83
+ return resolveHarnessRuntimePath(workspaceRoot, DEFAULT_GATEWAY_LOCK_PATH, env);
84
+ }
85
+
77
86
  export function normalizeGatewayHost(
78
87
  input: string | null | undefined,
79
88
  fallback = DEFAULT_GATEWAY_HOST,
@@ -149,6 +158,9 @@ export function parseGatewayRecordText(text: string): GatewayRecord | null {
149
158
  const workspaceRoot = readNonEmptyString(record['workspaceRoot']);
150
159
  const authTokenRaw = record['authToken'];
151
160
  const authToken = authTokenRaw === null ? null : readNonEmptyString(authTokenRaw);
161
+ const gatewayRunIdRaw = record['gatewayRunId'];
162
+ const gatewayRunId =
163
+ gatewayRunIdRaw === undefined ? undefined : readNonEmptyString(gatewayRunIdRaw);
152
164
 
153
165
  if (
154
166
  pid === null ||
@@ -157,10 +169,12 @@ export function parseGatewayRecordText(text: string): GatewayRecord | null {
157
169
  stateDbPath === null ||
158
170
  startedAt === null ||
159
171
  workspaceRoot === null ||
160
- (authToken === null && authTokenRaw !== null)
172
+ (authToken === null && authTokenRaw !== null) ||
173
+ (gatewayRunIdRaw !== undefined && gatewayRunId === null)
161
174
  ) {
162
175
  return null;
163
176
  }
177
+ const parsedGatewayRunId = gatewayRunId === null ? undefined : gatewayRunId;
164
178
 
165
179
  return {
166
180
  version,
@@ -171,6 +185,7 @@ export function parseGatewayRecordText(text: string): GatewayRecord | null {
171
185
  stateDbPath,
172
186
  startedAt,
173
187
  workspaceRoot,
188
+ ...(parsedGatewayRunId === undefined ? {} : { gatewayRunId: parsedGatewayRunId }),
174
189
  };
175
190
  }
176
191
 
@@ -7,6 +7,7 @@ import {
7
7
  unlinkSync,
8
8
  writeFileSync,
9
9
  } from 'node:fs';
10
+ import { homedir } from 'node:os';
10
11
  import { dirname, resolve } from 'node:path';
11
12
  import { fileURLToPath } from 'node:url';
12
13
 
@@ -1435,14 +1436,24 @@ export function resolveHarnessConfigDirectory(
1435
1436
  env: NodeJS.ProcessEnv = process.env,
1436
1437
  ): string {
1437
1438
  const xdgConfigHome = readNonEmptyEnvPath(env.XDG_CONFIG_HOME);
1439
+ const homeDirectory = readNonEmptyEnvPath(env.HOME) ?? readNonEmptyEnvPath(homedir());
1440
+ return resolveHarnessConfigDirectoryFromRoots(cwd, xdgConfigHome, homeDirectory);
1441
+ }
1442
+
1443
+ export function resolveHarnessConfigDirectoryFromRoots(
1444
+ cwd: string,
1445
+ xdgConfigHome: string | null,
1446
+ homeDirectory: string | null,
1447
+ ): string {
1438
1448
  if (xdgConfigHome !== null) {
1439
1449
  return resolve(xdgConfigHome, HARNESS_CONFIG_XDG_DIRECTORY_NAME);
1440
1450
  }
1441
- const homeDirectory = readNonEmptyEnvPath(env.HOME);
1442
1451
  if (homeDirectory !== null) {
1443
1452
  return resolve(homeDirectory, HARNESS_CONFIG_HOME_DIRECTORY_NAME);
1444
1453
  }
1445
- return resolve(cwd, HARNESS_CONFIG_HOME_DIRECTORY_NAME);
1454
+ throw new Error(
1455
+ `unable to resolve harness config directory: HOME and XDG_CONFIG_HOME are unset (cwd=${cwd})`,
1456
+ );
1446
1457
  }
1447
1458
 
1448
1459
  export function resolveHarnessConfigPath(
@@ -39,11 +39,7 @@ export function resolveHarnessWorkspaceDirectory(
39
39
  invocationDirectory: string,
40
40
  env: NodeJS.ProcessEnv = process.env,
41
41
  ): string {
42
- const legacyWorkspaceDirectory = resolveLegacyHarnessDirectory(invocationDirectory);
43
42
  const configDirectory = resolveHarnessConfigDirectory(invocationDirectory, env);
44
- if (resolve(configDirectory) === legacyWorkspaceDirectory) {
45
- return legacyWorkspaceDirectory;
46
- }
47
43
  return resolve(
48
44
  configDirectory,
49
45
  HARNESS_WORKSPACES_DIRECTORY,
@@ -74,13 +70,14 @@ export function resolveHarnessRuntimePath(
74
70
  pathValue: string,
75
71
  env: NodeJS.ProcessEnv = process.env,
76
72
  ): string {
73
+ const workspaceRuntimeDirectory = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
77
74
  const normalizedPath = pathValue.trim();
78
75
  if (normalizedPath.length === 0 || normalizedPath === HARNESS_LEGACY_RELATIVE_ROOT) {
79
- return resolveHarnessWorkspaceDirectory(invocationDirectory, env);
76
+ return workspaceRuntimeDirectory;
80
77
  }
81
78
  if (normalizedPath.startsWith(`${HARNESS_LEGACY_RELATIVE_ROOT}/`)) {
82
79
  return resolve(
83
- resolveHarnessWorkspaceDirectory(invocationDirectory, env),
80
+ workspaceRuntimeDirectory,
84
81
  normalizedPath.slice(`${HARNESS_LEGACY_RELATIVE_ROOT}/`.length),
85
82
  );
86
83
  }
@@ -88,5 +85,5 @@ export function resolveHarnessRuntimePath(
88
85
  if (expandedHomePath !== null) {
89
86
  return expandedHomePath;
90
87
  }
91
- return resolve(invocationDirectory, normalizedPath);
88
+ return resolve(workspaceRuntimeDirectory, normalizedPath);
92
89
  }
@@ -1,6 +1,20 @@
1
- import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
1
+ import {
2
+ copyFileSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
2
11
  import { dirname, resolve } from 'node:path';
3
- import { HARNESS_CONFIG_FILE_NAME, resolveHarnessConfigDirectory } from './config-core.ts';
12
+ import {
13
+ DEFAULT_HARNESS_CONFIG,
14
+ HARNESS_CONFIG_FILE_NAME,
15
+ parseHarnessConfigText,
16
+ resolveHarnessConfigDirectory,
17
+ } from './config-core.ts';
4
18
  import {
5
19
  resolveHarnessWorkspaceDirectory,
6
20
  resolveLegacyHarnessDirectory,
@@ -8,6 +22,7 @@ import {
8
22
 
9
23
  const LEGACY_SECRETS_FILE_NAME = 'secrets.env';
10
24
  const MIGRATION_MARKER_FILE_NAME = '.legacy-layout-migration-v1';
25
+ const MIGRATION_CONFIG_BACKUP_FILE_NAME = `${HARNESS_CONFIG_FILE_NAME}.pre-migration.bak`;
11
26
  const LEGACY_RUNTIME_EXCLUDE_NAMES = new Set([
12
27
  HARNESS_CONFIG_FILE_NAME,
13
28
  LEGACY_SECRETS_FILE_NAME,
@@ -18,9 +33,18 @@ interface HarnessLegacyLayoutMigrationResult {
18
33
  readonly migrated: boolean;
19
34
  readonly migratedEntries: number;
20
35
  readonly configCopied: boolean;
36
+ readonly configReplacedExisting: boolean;
37
+ readonly configBackupPath: string | null;
21
38
  readonly secretsCopied: boolean;
22
39
  readonly skipped: boolean;
23
40
  readonly markerPath: string;
41
+ readonly legacyRootRemoved: boolean;
42
+ }
43
+
44
+ interface ConfigCopyResult {
45
+ readonly copied: boolean;
46
+ readonly replacedExisting: boolean;
47
+ readonly backupPath: string | null;
24
48
  }
25
49
 
26
50
  function copyFileIfMissing(sourcePath: string, targetPath: string): boolean {
@@ -46,11 +70,96 @@ function copyEntryIfMissing(sourcePath: string, targetPath: string): boolean {
46
70
  return !targetExisted;
47
71
  }
48
72
 
73
+ function configEqualsDefaultConfig(text: string): boolean {
74
+ try {
75
+ const parsed = parseHarnessConfigText(text);
76
+ return JSON.stringify(parsed) === JSON.stringify(DEFAULT_HARNESS_CONFIG);
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function copyConfigIfGlobalUninitialized(sourcePath: string, targetPath: string): ConfigCopyResult {
83
+ if (!existsSync(sourcePath)) {
84
+ return {
85
+ copied: false,
86
+ replacedExisting: false,
87
+ backupPath: null,
88
+ };
89
+ }
90
+ if (!existsSync(targetPath)) {
91
+ mkdirSync(dirname(targetPath), { recursive: true });
92
+ copyFileSync(sourcePath, targetPath);
93
+ return {
94
+ copied: true,
95
+ replacedExisting: false,
96
+ backupPath: null,
97
+ };
98
+ }
99
+
100
+ const targetText = readFileSync(targetPath, 'utf8');
101
+ const targetUninitialized =
102
+ targetText.trim().length === 0 || configEqualsDefaultConfig(targetText);
103
+ if (!targetUninitialized) {
104
+ return {
105
+ copied: false,
106
+ replacedExisting: false,
107
+ backupPath: null,
108
+ };
109
+ }
110
+
111
+ const backupPath = resolve(dirname(targetPath), MIGRATION_CONFIG_BACKUP_FILE_NAME);
112
+ if (!existsSync(backupPath)) {
113
+ copyFileSync(targetPath, backupPath);
114
+ }
115
+ copyFileSync(sourcePath, targetPath);
116
+ return {
117
+ copied: true,
118
+ replacedExisting: true,
119
+ backupPath,
120
+ };
121
+ }
122
+
49
123
  function writeMigrationMarker(markerPath: string): void {
50
124
  mkdirSync(dirname(markerPath), { recursive: true });
51
125
  writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8');
52
126
  }
53
127
 
128
+ function removeLegacyRootIfSafe(
129
+ legacyRoot: string,
130
+ configDirectory: string,
131
+ workspaceDirectory: string,
132
+ ): boolean {
133
+ if (!existsSync(legacyRoot) || resolve(configDirectory) === legacyRoot) {
134
+ return false;
135
+ }
136
+
137
+ const legacyEntries = readdirSync(legacyRoot, { withFileTypes: true }).map((entry) => entry.name);
138
+ for (const entryName of legacyEntries) {
139
+ if (entryName === HARNESS_CONFIG_FILE_NAME) {
140
+ if (!existsSync(resolve(configDirectory, HARNESS_CONFIG_FILE_NAME))) {
141
+ return false;
142
+ }
143
+ continue;
144
+ }
145
+ if (entryName === LEGACY_SECRETS_FILE_NAME) {
146
+ if (!existsSync(resolve(configDirectory, LEGACY_SECRETS_FILE_NAME))) {
147
+ return false;
148
+ }
149
+ continue;
150
+ }
151
+ if (entryName === 'workspaces') {
152
+ continue;
153
+ }
154
+ if (!existsSync(resolve(workspaceDirectory, entryName))) {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ rmSync(legacyRoot, { recursive: true, force: true });
160
+ return !existsSync(legacyRoot);
161
+ }
162
+
54
163
  export function migrateLegacyHarnessLayout(
55
164
  invocationDirectory: string,
56
165
  env: NodeJS.ProcessEnv = process.env,
@@ -60,7 +169,7 @@ export function migrateLegacyHarnessLayout(
60
169
  const workspaceDirectory = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
61
170
  const markerPath = resolve(workspaceDirectory, MIGRATION_MARKER_FILE_NAME);
62
171
 
63
- const configCopied = copyFileIfMissing(
172
+ const configCopy = copyConfigIfGlobalUninitialized(
64
173
  resolve(legacyRoot, HARNESS_CONFIG_FILE_NAME),
65
174
  resolve(configDirectory, HARNESS_CONFIG_FILE_NAME),
66
175
  );
@@ -68,38 +177,50 @@ export function migrateLegacyHarnessLayout(
68
177
  resolve(legacyRoot, LEGACY_SECRETS_FILE_NAME),
69
178
  resolve(configDirectory, LEGACY_SECRETS_FILE_NAME),
70
179
  );
180
+ const withCleanupResult = (
181
+ result: Omit<HarnessLegacyLayoutMigrationResult, 'legacyRootRemoved'>,
182
+ ): HarnessLegacyLayoutMigrationResult => ({
183
+ ...result,
184
+ legacyRootRemoved: removeLegacyRootIfSafe(legacyRoot, configDirectory, workspaceDirectory),
185
+ });
71
186
 
72
187
  if (resolve(configDirectory) === legacyRoot) {
73
- return {
74
- migrated: configCopied || secretsCopied,
188
+ return withCleanupResult({
189
+ migrated: configCopy.copied || secretsCopied,
75
190
  migratedEntries: 0,
76
- configCopied,
191
+ configCopied: configCopy.copied,
192
+ configReplacedExisting: configCopy.replacedExisting,
193
+ configBackupPath: configCopy.backupPath,
77
194
  secretsCopied,
78
195
  skipped: true,
79
196
  markerPath,
80
- };
197
+ });
81
198
  }
82
199
 
83
200
  if (!existsSync(legacyRoot)) {
84
- return {
85
- migrated: configCopied || secretsCopied,
201
+ return withCleanupResult({
202
+ migrated: configCopy.copied || secretsCopied,
86
203
  migratedEntries: 0,
87
- configCopied,
204
+ configCopied: configCopy.copied,
205
+ configReplacedExisting: configCopy.replacedExisting,
206
+ configBackupPath: configCopy.backupPath,
88
207
  secretsCopied,
89
208
  skipped: true,
90
209
  markerPath,
91
- };
210
+ });
92
211
  }
93
212
 
94
213
  if (existsSync(markerPath)) {
95
- return {
96
- migrated: configCopied || secretsCopied,
214
+ return withCleanupResult({
215
+ migrated: configCopy.copied || secretsCopied,
97
216
  migratedEntries: 0,
98
- configCopied,
217
+ configCopied: configCopy.copied,
218
+ configReplacedExisting: configCopy.replacedExisting,
219
+ configBackupPath: configCopy.backupPath,
99
220
  secretsCopied,
100
221
  skipped: true,
101
222
  markerPath,
102
- };
223
+ });
103
224
  }
104
225
 
105
226
  const legacyEntries = readdirSync(legacyRoot, { withFileTypes: true })
@@ -119,12 +240,14 @@ export function migrateLegacyHarnessLayout(
119
240
  writeMigrationMarker(markerPath);
120
241
  }
121
242
 
122
- return {
123
- migrated: configCopied || secretsCopied || migratedEntries > 0,
243
+ return withCleanupResult({
244
+ migrated: configCopy.copied || secretsCopied || migratedEntries > 0,
124
245
  migratedEntries,
125
- configCopied,
246
+ configCopied: configCopy.copied,
247
+ configReplacedExisting: configCopy.replacedExisting,
248
+ configBackupPath: configCopy.backupPath,
126
249
  secretsCopied,
127
250
  skipped: false,
128
251
  markerPath,
129
- };
252
+ });
130
253
  }
@@ -1,9 +1,7 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
3
  import { resolveHarnessConfigDirectory } from './config-core.ts';
4
4
 
5
- export const HARNESS_SECRETS_FILE_PATH = '.harness/secrets.env';
6
-
7
5
  interface HarnessSecretEntry {
8
6
  readonly key: string;
9
7
  readonly value: string;
@@ -23,6 +21,20 @@ interface LoadedHarnessSecrets {
23
21
  readonly skippedKeys: readonly string[];
24
22
  }
25
23
 
24
+ interface UpsertHarnessSecretOptions {
25
+ readonly key: string;
26
+ readonly value: string;
27
+ readonly cwd?: string;
28
+ readonly filePath?: string;
29
+ readonly env?: NodeJS.ProcessEnv;
30
+ }
31
+
32
+ interface UpsertHarnessSecretResult {
33
+ readonly filePath: string;
34
+ readonly createdFile: boolean;
35
+ readonly replacedExisting: boolean;
36
+ }
37
+
26
38
  function isValidSecretKey(value: string): boolean {
27
39
  return /^[A-Za-z_][A-Za-z0-9_]*$/u.test(value);
28
40
  }
@@ -107,6 +119,38 @@ function parseLineValue(rawValue: string, lineNumber: number): string {
107
119
  return rawValue.replace(/\s+#.*$/u, '').trim();
108
120
  }
109
121
 
122
+ function parseSecretLineKey(line: string): string | null {
123
+ const trimmed = line.trim();
124
+ if (trimmed.length === 0 || trimmed.startsWith('#')) {
125
+ return null;
126
+ }
127
+ const withoutExport = trimmed.startsWith('export ')
128
+ ? trimmed.slice('export '.length).trimStart()
129
+ : trimmed;
130
+ const equalIndex = withoutExport.indexOf('=');
131
+ if (equalIndex <= 0) {
132
+ return null;
133
+ }
134
+ const key = withoutExport.slice(0, equalIndex).trim();
135
+ return isValidSecretKey(key) ? key : null;
136
+ }
137
+
138
+ function encodeSecretValue(value: string): string {
139
+ if (value.length === 0) {
140
+ return '""';
141
+ }
142
+ if (/^[A-Za-z0-9._:@/+,-]+$/u.test(value)) {
143
+ return value;
144
+ }
145
+ const escaped = value
146
+ .replaceAll('\\', '\\\\')
147
+ .replaceAll('"', '\\"')
148
+ .replaceAll('\n', '\\n')
149
+ .replaceAll('\r', '\\r')
150
+ .replaceAll('\t', '\\t');
151
+ return `"${escaped}"`;
152
+ }
153
+
110
154
  function parseHarnessSecretLine(line: string, lineNumber: number): HarnessSecretEntry | null {
111
155
  const trimmed = line.trim();
112
156
  if (trimmed.length === 0 || trimmed.startsWith('#')) {
@@ -186,3 +230,47 @@ export function loadHarnessSecrets(options: LoadHarnessSecretsOptions = {}): Loa
186
230
  skippedKeys,
187
231
  };
188
232
  }
233
+
234
+ export function upsertHarnessSecret(
235
+ options: UpsertHarnessSecretOptions,
236
+ ): UpsertHarnessSecretResult {
237
+ const key = options.key.trim();
238
+ if (!isValidSecretKey(key)) {
239
+ throw new Error(`invalid secret key: ${options.key}`);
240
+ }
241
+ const cwd = options.cwd ?? process.cwd();
242
+ const env = options.env ?? process.env;
243
+ const filePath = resolveHarnessSecretsPath(cwd, options.filePath, env);
244
+ const hadFile = existsSync(filePath);
245
+ const existingText = hadFile ? readFileSync(filePath, 'utf8') : '';
246
+ const sourceLines = existingText.split(/\r?\n/u);
247
+ if (sourceLines[sourceLines.length - 1] === '') {
248
+ sourceLines.pop();
249
+ }
250
+ const nextLines: string[] = [];
251
+ const encoded = encodeSecretValue(options.value);
252
+ let replacedExisting = false;
253
+ for (const line of sourceLines) {
254
+ const lineKey = parseSecretLineKey(line);
255
+ if (lineKey !== key) {
256
+ nextLines.push(line);
257
+ continue;
258
+ }
259
+ if (!replacedExisting) {
260
+ nextLines.push(`${key}=${encoded}`);
261
+ replacedExisting = true;
262
+ }
263
+ }
264
+ if (!replacedExisting) {
265
+ nextLines.push(`${key}=${encoded}`);
266
+ }
267
+ mkdirSync(dirname(filePath), { recursive: true });
268
+ const tempPath = `${filePath}.tmp.${String(process.pid)}`;
269
+ writeFileSync(tempPath, `${nextLines.join('\n')}\n`, 'utf8');
270
+ renameSync(tempPath, filePath);
271
+ return {
272
+ filePath,
273
+ createdFile: !hadFile,
274
+ replacedExisting,
275
+ };
276
+ }