@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.
- package/README.md +36 -155
- package/package.json +3 -1
- package/packages/harness-ai/src/anthropic-client.ts +99 -0
- package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
- package/packages/harness-ai/src/anthropic-provider.ts +82 -0
- package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
- package/packages/harness-ai/src/index.ts +36 -0
- package/packages/harness-ai/src/json-parse.ts +66 -0
- package/packages/harness-ai/src/sse.ts +80 -0
- package/packages/harness-ai/src/stream-object.ts +96 -0
- package/packages/harness-ai/src/stream-text.ts +1340 -0
- package/packages/harness-ai/src/types.ts +330 -0
- package/packages/harness-ai/src/ui-stream.ts +217 -0
- package/scripts/codex-live-mux-runtime.ts +265 -14
- package/scripts/control-plane-daemon.ts +33 -5
- package/scripts/harness.ts +579 -134
- package/src/cli/default-gateway-pointer.ts +193 -0
- package/src/cli/gateway-record.ts +16 -1
- package/src/config/config-core.ts +13 -2
- package/src/config/harness-paths.ts +4 -7
- package/src/config/harness-runtime-migration.ts +142 -19
- package/src/config/secrets-core.ts +92 -4
- package/src/control-plane/prompt/thread-title-namer.ts +316 -0
- package/src/control-plane/stream-command-parser.ts +12 -0
- package/src/control-plane/stream-protocol.ts +6 -0
- package/src/control-plane/stream-server-background.ts +18 -2
- package/src/control-plane/stream-server-command.ts +14 -0
- package/src/control-plane/stream-server.ts +460 -28
- package/src/domain/conversations.ts +11 -7
- package/src/domain/workspace.ts +9 -0
- package/src/mux/input-shortcuts.ts +38 -1
- package/src/mux/live-mux/git-parsing.ts +40 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
- package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
- package/src/mux/live-mux/modal-input-reducers.ts +34 -1
- package/src/mux/live-mux/modal-overlays.ts +45 -0
- package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
- package/src/mux/render-frame.ts +1 -1
- package/src/mux/task-screen-keybindings.ts +29 -1
- package/src/services/control-plane.ts +22 -0
- package/src/services/runtime-control-actions.ts +69 -0
- package/src/services/runtime-conversation-activation.ts +25 -0
- package/src/services/runtime-conversation-starter.ts +31 -7
- package/src/services/runtime-input-router.ts +6 -0
- package/src/services/runtime-modal-input.ts +18 -0
- package/src/services/runtime-navigation-input.ts +4 -0
- package/src/services/runtime-rail-input.ts +5 -0
- package/src/services/runtime-repository-actions.ts +2 -0
- package/src/services/runtime-workspace-actions.ts +5 -0
- package/src/store/control-plane-store.ts +36 -0
- package/src/store/event-store.ts +36 -0
- package/src/ui/global-shortcut-input.ts +2 -0
- package/src/ui/input.ts +31 -0
- 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
|
-
|
|
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
|
|
76
|
+
return workspaceRuntimeDirectory;
|
|
80
77
|
}
|
|
81
78
|
if (normalizedPath.startsWith(`${HARNESS_LEGACY_RELATIVE_ROOT}/`)) {
|
|
82
79
|
return resolve(
|
|
83
|
-
|
|
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(
|
|
88
|
+
return resolve(workspaceRuntimeDirectory, normalizedPath);
|
|
92
89
|
}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|