@wenrwa/marketplace-cli 0.5.23 → 0.6.2
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/dist/__tests__/auth-api-key-format.test.d.ts +1 -0
- package/dist/__tests__/auth-api-key-format.test.js +41 -0
- package/dist/__tests__/auth-identity.test.d.ts +1 -0
- package/dist/__tests__/auth-identity.test.js +46 -0
- package/dist/__tests__/auth-login-core.test.d.ts +1 -0
- package/dist/__tests__/auth-login-core.test.js +145 -0
- package/dist/__tests__/auth.test.js +246 -57
- package/dist/__tests__/bridge-bundle-installer.test.js +101 -0
- package/dist/__tests__/bridge-context-writer.test.js +168 -1
- package/dist/__tests__/bridge-local-context-writer.test.js +51 -2
- package/dist/__tests__/bridge-memory-brief.test.d.ts +1 -0
- package/dist/__tests__/bridge-memory-brief.test.js +107 -0
- package/dist/__tests__/bridge-memory-pointer.test.d.ts +1 -0
- package/dist/__tests__/bridge-memory-pointer.test.js +92 -0
- package/dist/__tests__/client.test.js +26 -4
- package/dist/__tests__/config.test.js +36 -4
- package/dist/__tests__/openBrowser.test.d.ts +1 -0
- package/dist/__tests__/openBrowser.test.js +42 -0
- package/dist/__tests__/prompt.test.d.ts +1 -0
- package/dist/__tests__/prompt.test.js +49 -0
- package/dist/auth/identity.d.ts +22 -0
- package/dist/auth/identity.js +20 -0
- package/dist/auth/login.d.ts +80 -0
- package/dist/auth/login.js +61 -0
- package/dist/bridge/apiClient.d.ts +25 -0
- package/dist/bridge/apiClient.js +7 -1
- package/dist/bridge/bundleInstaller.d.ts +14 -0
- package/dist/bridge/bundleInstaller.js +56 -2
- package/dist/bridge/contextWriter.d.ts +24 -1
- package/dist/bridge/contextWriter.js +61 -5
- package/dist/bridge/localContextWriter.d.ts +13 -1
- package/dist/bridge/localContextWriter.js +31 -4
- package/dist/bridge/memoryBrief.d.ts +41 -0
- package/dist/bridge/memoryBrief.js +63 -0
- package/dist/bridge/memoryPointer.d.ts +49 -0
- package/dist/bridge/memoryPointer.js +62 -0
- package/dist/bridge/pairing.js +7 -1
- package/dist/bridge/relayClient.d.ts +20 -1
- package/dist/bridge/relayClient.js +45 -6
- package/dist/bridge/server.d.ts +10 -1
- package/dist/bridge/server.js +29 -10
- package/dist/commands/auth.d.ts +17 -0
- package/dist/commands/auth.js +202 -27
- package/dist/commands/bridge.js +82 -2
- package/dist/utils/apiKeyFormat.d.ts +9 -0
- package/dist/utils/apiKeyFormat.js +26 -0
- package/dist/utils/client.js +7 -1
- package/dist/utils/config.d.ts +3 -0
- package/dist/utils/config.js +28 -2
- package/dist/utils/openBrowser.d.ts +16 -0
- package/dist/utils/openBrowser.js +30 -0
- package/dist/utils/prompt.d.ts +9 -0
- package/dist/utils/prompt.js +63 -0
- package/package.json +2 -2
|
@@ -37,6 +37,11 @@ const fs = __importStar(require("fs"));
|
|
|
37
37
|
const config_1 = require("../utils/config");
|
|
38
38
|
jest.mock('fs');
|
|
39
39
|
const mockedFs = fs;
|
|
40
|
+
// wha-08: requireConfig() now validates the stored credential looks like a
|
|
41
|
+
// real API key (wm_sk_…) — every fixture that goes through requireConfig()
|
|
42
|
+
// needs a realistic-looking key; loadConfig() itself is format-agnostic
|
|
43
|
+
// (it's a dumb JSON read — the validation lives in requireConfig()).
|
|
44
|
+
const REAL_KEY = 'wm_sk_' + 'a'.repeat(64);
|
|
40
45
|
afterEach(() => {
|
|
41
46
|
jest.restoreAllMocks();
|
|
42
47
|
delete process.env.MARKETPLACE_API_URL;
|
|
@@ -67,8 +72,8 @@ describe('saveConfig()', () => {
|
|
|
67
72
|
});
|
|
68
73
|
});
|
|
69
74
|
describe('requireConfig()', () => {
|
|
70
|
-
it('returns config when it exists', () => {
|
|
71
|
-
const config = { apiKey:
|
|
75
|
+
it('returns config when it exists and looks like a real API key', () => {
|
|
76
|
+
const config = { apiKey: REAL_KEY, apiUrl: 'http://test' };
|
|
72
77
|
mockedFs.readFileSync.mockReturnValue(JSON.stringify(config));
|
|
73
78
|
expect((0, config_1.requireConfig)()).toEqual(config);
|
|
74
79
|
});
|
|
@@ -81,6 +86,33 @@ describe('requireConfig()', () => {
|
|
|
81
86
|
exitSpy.mockRestore();
|
|
82
87
|
errSpy.mockRestore();
|
|
83
88
|
});
|
|
89
|
+
// wha-08: the interactive CLI used to store a bare wallet public key as
|
|
90
|
+
// "apiKey" and send it as X-Wallet-Pubkey (the retired header). An
|
|
91
|
+
// existing user with that old config gets a clear message to sign in
|
|
92
|
+
// properly instead of the client silently sending a spoofable header.
|
|
93
|
+
it('exits with clear guidance when the stored credential looks like a wallet address, not an API key', () => {
|
|
94
|
+
const config = { apiKey: 'AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9', apiUrl: 'http://test' };
|
|
95
|
+
mockedFs.readFileSync.mockReturnValue(JSON.stringify(config));
|
|
96
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
97
|
+
const errSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
98
|
+
(0, config_1.requireConfig)();
|
|
99
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
100
|
+
const messages = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
101
|
+
expect(messages).toContain('wen-market auth login');
|
|
102
|
+
exitSpy.mockRestore();
|
|
103
|
+
errSpy.mockRestore();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('clearConfig()', () => {
|
|
107
|
+
it('removes the config file', () => {
|
|
108
|
+
mockedFs.unlinkSync.mockReturnValue(undefined);
|
|
109
|
+
(0, config_1.clearConfig)();
|
|
110
|
+
expect(mockedFs.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('config.json'));
|
|
111
|
+
});
|
|
112
|
+
it('does not throw when the config file does not already exist', () => {
|
|
113
|
+
mockedFs.unlinkSync.mockImplementation(() => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); });
|
|
114
|
+
expect(() => (0, config_1.clearConfig)()).not.toThrow();
|
|
115
|
+
});
|
|
84
116
|
});
|
|
85
117
|
describe('getApiUrl()', () => {
|
|
86
118
|
it('prefers env var over config', () => {
|
|
@@ -92,8 +124,8 @@ describe('getApiUrl()', () => {
|
|
|
92
124
|
mockedFs.readFileSync.mockReturnValue(JSON.stringify(config));
|
|
93
125
|
expect((0, config_1.getApiUrl)()).toBe('http://config-url');
|
|
94
126
|
});
|
|
95
|
-
it('falls back to
|
|
127
|
+
it('falls back to the production marketplace when no config or env (a published CLI must work out of the box)', () => {
|
|
96
128
|
mockedFs.readFileSync.mockImplementation(() => { throw new Error('ENOENT'); });
|
|
97
|
-
expect((0, config_1.getApiUrl)()).toBe('
|
|
129
|
+
expect((0, config_1.getApiUrl)()).toBe('https://api.wenrwa.com/api/v1');
|
|
98
130
|
});
|
|
99
131
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* TDD — wha-08: `wen-market auth login` tries to open the approval URL in a
|
|
5
|
+
* browser as best-effort convenience. The code + URL are always printed
|
|
6
|
+
* regardless (works over SSH), so any failure here must be swallowed, never
|
|
7
|
+
* surfaced. spawnFn is injectable so this never touches a real process.
|
|
8
|
+
*/
|
|
9
|
+
const openBrowser_1 = require("../utils/openBrowser");
|
|
10
|
+
function fakeChild() {
|
|
11
|
+
return { unref: jest.fn(), on: jest.fn() };
|
|
12
|
+
}
|
|
13
|
+
describe('openBrowser', () => {
|
|
14
|
+
it('uses "open" on darwin', () => {
|
|
15
|
+
const child = fakeChild();
|
|
16
|
+
const spawnFn = jest.fn().mockReturnValue(child);
|
|
17
|
+
(0, openBrowser_1.openBrowser)('https://x/settings/cli-login?code=HIVE-7K3Q', { platform: 'darwin', spawnFn });
|
|
18
|
+
expect(spawnFn).toHaveBeenCalledWith('open', ['https://x/settings/cli-login?code=HIVE-7K3Q'], expect.any(Object));
|
|
19
|
+
});
|
|
20
|
+
it('uses "xdg-open" on linux', () => {
|
|
21
|
+
const child = fakeChild();
|
|
22
|
+
const spawnFn = jest.fn().mockReturnValue(child);
|
|
23
|
+
(0, openBrowser_1.openBrowser)('https://x', { platform: 'linux', spawnFn });
|
|
24
|
+
expect(spawnFn).toHaveBeenCalledWith('xdg-open', ['https://x'], expect.any(Object));
|
|
25
|
+
});
|
|
26
|
+
it('uses a shell "start" command on win32', () => {
|
|
27
|
+
const child = fakeChild();
|
|
28
|
+
const spawnFn = jest.fn().mockReturnValue(child);
|
|
29
|
+
(0, openBrowser_1.openBrowser)('https://x', { platform: 'win32', spawnFn });
|
|
30
|
+
expect(spawnFn).toHaveBeenCalledWith('cmd', expect.arrayContaining(['/c', 'start']), expect.any(Object));
|
|
31
|
+
});
|
|
32
|
+
it('never throws when spawning throws synchronously', () => {
|
|
33
|
+
const spawnFn = jest.fn().mockImplementation(() => { throw new Error('ENOENT'); });
|
|
34
|
+
expect(() => (0, openBrowser_1.openBrowser)('https://x', { platform: 'linux', spawnFn })).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
it('never throws when the child process reports an error', () => {
|
|
37
|
+
const child = { unref: jest.fn(), on: jest.fn((event, cb) => { if (event === 'error')
|
|
38
|
+
cb(new Error('spawn failed')); }) };
|
|
39
|
+
const spawnFn = jest.fn().mockReturnValue(child);
|
|
40
|
+
expect(() => (0, openBrowser_1.openBrowser)('https://x', { platform: 'linux', spawnFn })).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* TDD — wha-08: the "Already signed in as X. Sign in again? (y/N)" prompt.
|
|
5
|
+
* Streams are injected (no real stdin/stdout) so this is testable without a
|
|
6
|
+
* TTY, using a PassThrough pipe like other stream-based CLI tests.
|
|
7
|
+
*/
|
|
8
|
+
const stream_1 = require("stream");
|
|
9
|
+
const prompt_1 = require("../utils/prompt");
|
|
10
|
+
function makeStreams() {
|
|
11
|
+
const input = new stream_1.PassThrough();
|
|
12
|
+
const output = new stream_1.PassThrough();
|
|
13
|
+
output.on('data', () => { }); // drain — readline writes the prompt text here
|
|
14
|
+
return { input, output };
|
|
15
|
+
}
|
|
16
|
+
describe('confirm', () => {
|
|
17
|
+
it('defaults to the given default value on an empty answer (Enter)', async () => {
|
|
18
|
+
const { input, output } = makeStreams();
|
|
19
|
+
const promise = (0, prompt_1.confirm)('Sign in again? (y/N) ', false, { input, output });
|
|
20
|
+
input.write('\n');
|
|
21
|
+
expect(await promise).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
it('returns true for "y"', async () => {
|
|
24
|
+
const { input, output } = makeStreams();
|
|
25
|
+
const promise = (0, prompt_1.confirm)('Sign in again? (y/N) ', false, { input, output });
|
|
26
|
+
input.write('y\n');
|
|
27
|
+
expect(await promise).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it('returns true for "yes", case-insensitively', async () => {
|
|
30
|
+
const { input, output } = makeStreams();
|
|
31
|
+
const promise = (0, prompt_1.confirm)('Sign in again? (y/N) ', false, { input, output });
|
|
32
|
+
input.write('YES\n');
|
|
33
|
+
expect(await promise).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('returns false for "n" even when the default is true', async () => {
|
|
36
|
+
const { input, output } = makeStreams();
|
|
37
|
+
const promise = (0, prompt_1.confirm)('Continue? (Y/n) ', true, { input, output });
|
|
38
|
+
input.write('n\n');
|
|
39
|
+
expect(await promise).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('isInteractive', () => {
|
|
43
|
+
it('is true only when both stdin and stdout are a TTY', () => {
|
|
44
|
+
expect((0, prompt_1.isInteractive)({ isTTY: true }, { isTTY: true })).toBe(true);
|
|
45
|
+
expect((0, prompt_1.isInteractive)({ isTTY: false }, { isTTY: true })).toBe(false);
|
|
46
|
+
expect((0, prompt_1.isInteractive)({ isTTY: true }, { isTTY: false })).toBe(false);
|
|
47
|
+
expect((0, prompt_1.isInteractive)({}, {})).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wha-08 — the one authenticated call `wen-market auth login` (device flow,
|
|
3
|
+
* to print "Signed in as <name>") and `auth login --api-key` (CI, to verify
|
|
4
|
+
* the key before saving it) both need. Sends the key as X-API-Key — never
|
|
5
|
+
* X-Wallet-Pubkey, the retired header this whole PRD exists to remove.
|
|
6
|
+
*
|
|
7
|
+
* GET /agents/me works for every account, agent or not: an account with no
|
|
8
|
+
* registered agent still comes back `{ success: true, agent: null }` (see
|
|
9
|
+
* MarketplaceController#getMyAgent), so a plain 200 already tells us the key
|
|
10
|
+
* is valid even when there's no name to show.
|
|
11
|
+
*/
|
|
12
|
+
import { FetchImpl } from '../bridge/apiClient';
|
|
13
|
+
export interface ResolveIdentityResult {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
name?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function resolveIdentity(opts: {
|
|
19
|
+
apiUrl: string;
|
|
20
|
+
apiKey: string;
|
|
21
|
+
fetchImpl?: FetchImpl;
|
|
22
|
+
}): Promise<ResolveIdentityResult>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveIdentity = resolveIdentity;
|
|
4
|
+
async function resolveIdentity(opts) {
|
|
5
|
+
const f = opts.fetchImpl ?? fetch;
|
|
6
|
+
try {
|
|
7
|
+
const res = await f(`${opts.apiUrl}/agents/me`, {
|
|
8
|
+
method: 'GET',
|
|
9
|
+
headers: { 'X-API-Key': opts.apiKey },
|
|
10
|
+
});
|
|
11
|
+
const json = await res.json().catch(() => ({}));
|
|
12
|
+
if (!res.ok || json?.success === false) {
|
|
13
|
+
return { ok: false, message: json?.error?.message || `HTTP ${res.status}` };
|
|
14
|
+
}
|
|
15
|
+
return { ok: true, name: json?.agent?.name || undefined };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
return { ok: false, message: err?.message || 'Could not reach the server' };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wha-08 — `wen-market auth login`: CLI browser login. Device-code pairing
|
|
3
|
+
* (purpose=cli_login on the shared lws-14 mechanism), same GitHub-CLI /
|
|
4
|
+
* Apple-TV pattern as bridge pairing (bridge/pairing.ts) — a code is minted,
|
|
5
|
+
* printed with the approval URL, and polled until a human decides in the
|
|
6
|
+
* browser.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately NOT shared with bridge/pairing.ts: `wen-market pair` (bridge
|
|
9
|
+
* pairing) must keep working byte-for-byte unchanged, and cli_login has an
|
|
10
|
+
* outcome bridge pairing's loop never handles — the approval screen lets the
|
|
11
|
+
* human DENY a CLI login, and this CLI needs to say so plainly rather than
|
|
12
|
+
* silently waiting out the clock (which is what runPairingPoll would do,
|
|
13
|
+
* since 'denied' falls through its pending/approved/expired switch).
|
|
14
|
+
*/
|
|
15
|
+
import { PairingPollResponse } from '../bridge/apiClient';
|
|
16
|
+
export interface LoginPollDeps {
|
|
17
|
+
poll: () => Promise<PairingPollResponse>;
|
|
18
|
+
sleep: (ms: number) => Promise<void>;
|
|
19
|
+
intervalMs: number;
|
|
20
|
+
deadlineMs: number;
|
|
21
|
+
now: () => number;
|
|
22
|
+
isCancelled: () => boolean;
|
|
23
|
+
}
|
|
24
|
+
export type LoginPollOutcome = {
|
|
25
|
+
outcome: 'approved';
|
|
26
|
+
apiKey: string;
|
|
27
|
+
} | {
|
|
28
|
+
outcome: 'denied';
|
|
29
|
+
} | {
|
|
30
|
+
outcome: 'expired';
|
|
31
|
+
} | {
|
|
32
|
+
outcome: 'timeout';
|
|
33
|
+
} | {
|
|
34
|
+
outcome: 'cancelled';
|
|
35
|
+
};
|
|
36
|
+
export declare const DEFAULT_LOGIN_POLL_INTERVAL_MS = 2000;
|
|
37
|
+
export declare const DEFAULT_LOGIN_POLL_DEADLINE_MS: number;
|
|
38
|
+
export declare function runLoginPoll(deps: LoginPollDeps): Promise<LoginPollOutcome>;
|
|
39
|
+
export interface LoginCoreDeps {
|
|
40
|
+
requestPairingCode: (opts: {
|
|
41
|
+
apiUrl: string;
|
|
42
|
+
deviceLabel: string;
|
|
43
|
+
purpose: 'cli_login';
|
|
44
|
+
}) => Promise<{
|
|
45
|
+
code: string;
|
|
46
|
+
pollToken: string;
|
|
47
|
+
expiresAt: string;
|
|
48
|
+
}>;
|
|
49
|
+
pollPairingCode: (opts: {
|
|
50
|
+
apiUrl: string;
|
|
51
|
+
pollToken: string;
|
|
52
|
+
}) => Promise<PairingPollResponse>;
|
|
53
|
+
hostname: () => string;
|
|
54
|
+
sleep: (ms: number) => Promise<void>;
|
|
55
|
+
now: () => number;
|
|
56
|
+
isCancelled: () => boolean;
|
|
57
|
+
intervalMs?: number;
|
|
58
|
+
deadlineMs?: number;
|
|
59
|
+
/** Fired the instant the code is minted, before polling starts, so the
|
|
60
|
+
* command shell can print it large (+ the approval URL) right away. */
|
|
61
|
+
onCodeReady?: (info: {
|
|
62
|
+
code: string;
|
|
63
|
+
expiresAt: string;
|
|
64
|
+
}) => void;
|
|
65
|
+
}
|
|
66
|
+
export interface LoginCoreOutcome {
|
|
67
|
+
code: string;
|
|
68
|
+
expiresAt: string;
|
|
69
|
+
result: LoginPollOutcome;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Pure core: request → poll, network and clock both injected. Deliberately
|
|
73
|
+
* does NOT write anything to disk — the command shell decides what to save
|
|
74
|
+
* (and resolves a display name) once it has the 'approved' outcome, so this
|
|
75
|
+
* stays a single-responsibility "run the device flow" function, mirroring
|
|
76
|
+
* pairCore's "pure core, thin command shell" split.
|
|
77
|
+
*/
|
|
78
|
+
export declare function loginCore(opts: {
|
|
79
|
+
apiUrl: string;
|
|
80
|
+
}, deps: LoginCoreDeps): Promise<LoginCoreOutcome>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_LOGIN_POLL_DEADLINE_MS = exports.DEFAULT_LOGIN_POLL_INTERVAL_MS = void 0;
|
|
4
|
+
exports.runLoginPoll = runLoginPoll;
|
|
5
|
+
exports.loginCore = loginCore;
|
|
6
|
+
// 2s cadence, ~10min overall deadline — matches the backend's pairing-code
|
|
7
|
+
// TTL (pairing.service.ts TTL_MS) so a locally timed-out poll and a
|
|
8
|
+
// server-expired code mean the same thing to the user.
|
|
9
|
+
exports.DEFAULT_LOGIN_POLL_INTERVAL_MS = 2000;
|
|
10
|
+
exports.DEFAULT_LOGIN_POLL_DEADLINE_MS = 10 * 60_000;
|
|
11
|
+
async function runLoginPoll(deps) {
|
|
12
|
+
const start = deps.now();
|
|
13
|
+
for (;;) {
|
|
14
|
+
if (deps.isCancelled())
|
|
15
|
+
return { outcome: 'cancelled' };
|
|
16
|
+
if (deps.now() - start >= deps.deadlineMs)
|
|
17
|
+
return { outcome: 'timeout' };
|
|
18
|
+
const result = await deps.poll();
|
|
19
|
+
if (deps.isCancelled())
|
|
20
|
+
return { outcome: 'cancelled' };
|
|
21
|
+
if (result.status === 'approved') {
|
|
22
|
+
const apiKey = result.apiKey;
|
|
23
|
+
// Defensive: an approved cli_login poll always carries the raw key
|
|
24
|
+
// (pairing.service.ts poll()) — anything else means something is
|
|
25
|
+
// badly wrong server-side, and "expired, try again" is a safer thing
|
|
26
|
+
// to tell the user than silently reporting success with no key.
|
|
27
|
+
if (!apiKey)
|
|
28
|
+
return { outcome: 'expired' };
|
|
29
|
+
return { outcome: 'approved', apiKey };
|
|
30
|
+
}
|
|
31
|
+
if (result.status === 'denied')
|
|
32
|
+
return { outcome: 'denied' };
|
|
33
|
+
if (result.status === 'expired')
|
|
34
|
+
return { outcome: 'expired' };
|
|
35
|
+
await deps.sleep(deps.intervalMs);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Pure core: request → poll, network and clock both injected. Deliberately
|
|
40
|
+
* does NOT write anything to disk — the command shell decides what to save
|
|
41
|
+
* (and resolves a display name) once it has the 'approved' outcome, so this
|
|
42
|
+
* stays a single-responsibility "run the device flow" function, mirroring
|
|
43
|
+
* pairCore's "pure core, thin command shell" split.
|
|
44
|
+
*/
|
|
45
|
+
async function loginCore(opts, deps) {
|
|
46
|
+
const { code, pollToken, expiresAt } = await deps.requestPairingCode({
|
|
47
|
+
apiUrl: opts.apiUrl,
|
|
48
|
+
deviceLabel: deps.hostname(),
|
|
49
|
+
purpose: 'cli_login',
|
|
50
|
+
});
|
|
51
|
+
deps.onCodeReady?.({ code, expiresAt });
|
|
52
|
+
const result = await runLoginPoll({
|
|
53
|
+
poll: () => deps.pollPairingCode({ apiUrl: opts.apiUrl, pollToken }),
|
|
54
|
+
sleep: deps.sleep,
|
|
55
|
+
intervalMs: deps.intervalMs ?? exports.DEFAULT_LOGIN_POLL_INTERVAL_MS,
|
|
56
|
+
deadlineMs: deps.deadlineMs ?? exports.DEFAULT_LOGIN_POLL_DEADLINE_MS,
|
|
57
|
+
now: deps.now,
|
|
58
|
+
isCancelled: deps.isCancelled,
|
|
59
|
+
});
|
|
60
|
+
return { code, expiresAt, result };
|
|
61
|
+
}
|
|
@@ -30,19 +30,44 @@ export interface PairingCodeResponse {
|
|
|
30
30
|
pollToken: string;
|
|
31
31
|
expiresAt: string;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* wha-08: `purpose` is optional and omitted by every bridge caller (pair.ts
|
|
35
|
+
* never passes it), so the request body stays byte-for-byte
|
|
36
|
+
* `{ deviceLabel }` for the bridge — JSON.stringify drops an undefined-
|
|
37
|
+
* valued key. Only `auth/login.ts` (cli_login) passes it explicitly.
|
|
38
|
+
*/
|
|
33
39
|
export declare function requestPairingCode(opts: {
|
|
34
40
|
apiUrl: string;
|
|
35
41
|
deviceLabel: string;
|
|
42
|
+
purpose?: 'bridge' | 'cli_login';
|
|
36
43
|
fetchImpl?: FetchImpl;
|
|
37
44
|
}): Promise<PairingCodeResponse>;
|
|
45
|
+
/**
|
|
46
|
+
* wha-08: widened (additively) to cover the cli_login purpose — a 'denied'
|
|
47
|
+
* status (the human declined in the browser — bridge pairing's poll loop
|
|
48
|
+
* never reaches this branch, see auth/login.ts's header comment) and an
|
|
49
|
+
* 'approved' response that carries a fresh API key instead of a connect
|
|
50
|
+
* token + device id. `purpose` is optional/untyped-through on the bridge
|
|
51
|
+
* variant since the real server has always sent it, but the bridge's own
|
|
52
|
+
* tests stub responses without it — this stays backward compatible.
|
|
53
|
+
*/
|
|
38
54
|
export type PairingPollResponse = {
|
|
39
55
|
success: true;
|
|
40
56
|
status: 'pending';
|
|
41
57
|
} | {
|
|
42
58
|
success: true;
|
|
43
59
|
status: 'approved';
|
|
60
|
+
purpose?: 'bridge';
|
|
44
61
|
connectToken: string;
|
|
45
62
|
deviceId: string;
|
|
63
|
+
} | {
|
|
64
|
+
success: true;
|
|
65
|
+
status: 'approved';
|
|
66
|
+
purpose: 'cli_login';
|
|
67
|
+
apiKey: string;
|
|
68
|
+
} | {
|
|
69
|
+
success: true;
|
|
70
|
+
status: 'denied';
|
|
46
71
|
} | {
|
|
47
72
|
success: true;
|
|
48
73
|
status: 'expired';
|
package/dist/bridge/apiClient.js
CHANGED
|
@@ -69,8 +69,14 @@ async function pairingRequest(apiUrl, path, body, fetchImpl) {
|
|
|
69
69
|
}
|
|
70
70
|
return json;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* wha-08: `purpose` is optional and omitted by every bridge caller (pair.ts
|
|
74
|
+
* never passes it), so the request body stays byte-for-byte
|
|
75
|
+
* `{ deviceLabel }` for the bridge — JSON.stringify drops an undefined-
|
|
76
|
+
* valued key. Only `auth/login.ts` (cli_login) passes it explicitly.
|
|
77
|
+
*/
|
|
72
78
|
function requestPairingCode(opts) {
|
|
73
|
-
return pairingRequest(opts.apiUrl, '/pairing/codes', { deviceLabel: opts.deviceLabel }, opts.fetchImpl);
|
|
79
|
+
return pairingRequest(opts.apiUrl, '/pairing/codes', { deviceLabel: opts.deviceLabel, purpose: opts.purpose }, opts.fetchImpl);
|
|
74
80
|
}
|
|
75
81
|
function pollPairingCode(opts) {
|
|
76
82
|
return pairingRequest(opts.apiUrl, '/pairing/poll', { pollToken: opts.pollToken }, opts.fetchImpl);
|
|
@@ -17,6 +17,15 @@ export interface BundleInstallerDeps {
|
|
|
17
17
|
mkdirp: (dir: string) => void;
|
|
18
18
|
/** Write (overwrite) a file. */
|
|
19
19
|
writeFile: (filePath: string, content: string) => void;
|
|
20
|
+
/**
|
|
21
|
+
* The three deps a crew SWAP needs. All optional together: absent (any older
|
|
22
|
+
* caller, or a half-wired dep set) → nothing is ever pruned and this module
|
|
23
|
+
* behaves exactly as it did before. Pruning deletes files inside a real
|
|
24
|
+
* checkout, so "not wired" must mean "do nothing", never "guess".
|
|
25
|
+
*/
|
|
26
|
+
listDir?: (dir: string) => string[];
|
|
27
|
+
readFile?: (filePath: string) => string;
|
|
28
|
+
remove?: (filePath: string) => void;
|
|
20
29
|
}
|
|
21
30
|
export type SkipReason = 'unsafe' | 'unmanaged';
|
|
22
31
|
export interface InstallBundleResult {
|
|
@@ -29,6 +38,11 @@ export interface InstallBundleResult {
|
|
|
29
38
|
path: string;
|
|
30
39
|
reason: SkipReason;
|
|
31
40
|
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Crew members removed because this bundle replaced them (relative paths).
|
|
43
|
+
* Reported so an install is auditable rather than silently destructive.
|
|
44
|
+
*/
|
|
45
|
+
pruned: string[];
|
|
32
46
|
}
|
|
33
47
|
/**
|
|
34
48
|
* A workspaceId is the sole dir segment under reposBaseDir — it must be a plain
|
|
@@ -65,6 +65,15 @@ exports.installBundle = installBundle;
|
|
|
65
65
|
* the crew files.
|
|
66
66
|
*/
|
|
67
67
|
const path = __importStar(require("path"));
|
|
68
|
+
/**
|
|
69
|
+
* The marker every forged crew member carries. Its presence is what
|
|
70
|
+
* distinguishes a file this installer put there from one the pilot wrote by
|
|
71
|
+
* hand in the same directory — and the pilot's own subagent must survive a
|
|
72
|
+
* crew swap. If Forge ever stops emitting it, the failure direction is a
|
|
73
|
+
* stale file left behind, never a deleted one.
|
|
74
|
+
*/
|
|
75
|
+
const CREW_FILE_MARKER = '## Prompt Defense Baseline';
|
|
76
|
+
const AGENTS_DIR_REL = '.claude/agents';
|
|
68
77
|
/**
|
|
69
78
|
* A workspaceId is the sole dir segment under reposBaseDir — it must be a plain
|
|
70
79
|
* id, never a path fragment. Reject anything that could climb out of / redirect
|
|
@@ -87,11 +96,12 @@ function isManaged(normalizedRel) {
|
|
|
87
96
|
function installBundle(bundle, deps) {
|
|
88
97
|
const written = [];
|
|
89
98
|
const skipped = [];
|
|
99
|
+
const pruned = [];
|
|
90
100
|
// fcct-27: the id is a path segment ONLY when we join it ourselves. An
|
|
91
101
|
// explicit workspaceRoot never puts it on the path, so there is nothing for
|
|
92
102
|
// it to climb out of — but the guard stays for the joining case.
|
|
93
103
|
if (!deps.workspaceRoot && !isSafeWorkspaceId(bundle.workspaceId)) {
|
|
94
|
-
return { workspaceRoot: null, written, skipped };
|
|
104
|
+
return { workspaceRoot: null, written, skipped, pruned };
|
|
95
105
|
}
|
|
96
106
|
const workspaceRoot = deps.workspaceRoot
|
|
97
107
|
? path.posix.resolve(deps.workspaceRoot)
|
|
@@ -128,5 +138,49 @@ function installBundle(bundle, deps) {
|
|
|
128
138
|
deps.writeFile(target, content);
|
|
129
139
|
written.push(normalized);
|
|
130
140
|
}
|
|
131
|
-
|
|
141
|
+
/*
|
|
142
|
+
A crew SWAP must replace its members, not pile a new crew on top of the old
|
|
143
|
+
one. Before this, installBundle only ever wrote: a live container was found
|
|
144
|
+
holding eight `.claude/agents/*.md` for a five-member crew (and another
|
|
145
|
+
checkout ten), so subagents from a crew the pilot had detached stayed
|
|
146
|
+
callable indefinitely.
|
|
147
|
+
|
|
148
|
+
Every condition below exists to make the failure direction "left a stale
|
|
149
|
+
file" rather than "deleted something of yours":
|
|
150
|
+
- only inside `.claude/agents`, so CLAUDE.md and the rest of `.claude/`
|
|
151
|
+
are untouchable here;
|
|
152
|
+
- only when this bundle installs at least one agent, so a CLAUDE.md-only
|
|
153
|
+
or skills-only bundle can never empty the crew;
|
|
154
|
+
- only files carrying CREW_FILE_MARKER, so a hand-written subagent living
|
|
155
|
+
in the same directory survives;
|
|
156
|
+
- only when the caller wired listDir/readFile/remove;
|
|
157
|
+
- and any error reading the directory abandons pruning entirely — an
|
|
158
|
+
install that wrote its files must not fail because cleanup could not.
|
|
159
|
+
*/
|
|
160
|
+
const installedAgents = new Set(written.filter((rel) => rel.startsWith(`${AGENTS_DIR_REL}/`)));
|
|
161
|
+
if (installedAgents.size > 0 && deps.listDir && deps.readFile && deps.remove) {
|
|
162
|
+
const agentsDir = path.posix.resolve(workspaceRoot, AGENTS_DIR_REL);
|
|
163
|
+
try {
|
|
164
|
+
for (const name of deps.listDir(agentsDir)) {
|
|
165
|
+
if (!name.endsWith('.md'))
|
|
166
|
+
continue;
|
|
167
|
+
const rel = `${AGENTS_DIR_REL}/${name}`;
|
|
168
|
+
if (installedAgents.has(rel))
|
|
169
|
+
continue;
|
|
170
|
+
const full = path.posix.resolve(agentsDir, name);
|
|
171
|
+
// Containment, same discipline as the write path above: a listing that
|
|
172
|
+
// somehow yields a climbing name must not delete outside the directory.
|
|
173
|
+
if (!full.startsWith(agentsDir + '/'))
|
|
174
|
+
continue;
|
|
175
|
+
if (!deps.readFile(full).includes(CREW_FILE_MARKER))
|
|
176
|
+
continue;
|
|
177
|
+
deps.remove(full);
|
|
178
|
+
pruned.push(rel);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Cleanup is best-effort by design; the bundle is already installed.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { workspaceRoot, written, skipped, pruned };
|
|
132
186
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const CONTEXT_START_MARKER = "<!-- HIVEOP:CONTEXT:START -->";
|
|
2
2
|
export declare const CONTEXT_END_MARKER = "<!-- HIVEOP:CONTEXT:END -->";
|
|
3
|
+
export declare const POINTERS_START_MARKER = "<!-- HIVEOP:POINTERS:START -->";
|
|
4
|
+
export declare const POINTERS_END_MARKER = "<!-- HIVEOP:POINTERS:END -->";
|
|
3
5
|
export interface ContextWriterDeps {
|
|
4
6
|
/** Root holding every workspace's checkout, e.g. `/data/repos`. */
|
|
5
7
|
reposBaseDir: string;
|
|
@@ -27,6 +29,27 @@ export interface ContextWriteResult {
|
|
|
27
29
|
*/
|
|
28
30
|
export declare function writeContext(input: {
|
|
29
31
|
workspaceId: string;
|
|
30
|
-
|
|
32
|
+
/** Absent only for a pointers-only refresh (wmr-07). */
|
|
33
|
+
content?: string;
|
|
31
34
|
workGraphAvailable?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* wmr-01 — whether to carry the workspace-memory guidance. Decided by the
|
|
37
|
+
* caller on the same signal as workGraphAvailable (WENRWA_CONNECT_TOKEN),
|
|
38
|
+
* because it names the same MCP tool registration. Absent/false is
|
|
39
|
+
* byte-identical to pre-wmr-01 output.
|
|
40
|
+
*/
|
|
41
|
+
memoryAvailable?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* wmr-03 — this workspace's composed memory brief, or null when the fetch
|
|
44
|
+
* failed or there is nothing yet. Never blocks the write (PRD D2): a null
|
|
45
|
+
* brief renders the standing instruction alone.
|
|
46
|
+
*/
|
|
47
|
+
memoryBrief?: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* wmr-07 — refresh ONLY the standing pointers, keeping whatever file
|
|
50
|
+
* content the region already holds. Used by the bridge when a session
|
|
51
|
+
* opens, which is the trigger that does not depend on the pilot having
|
|
52
|
+
* selected files or on a browser being connected at all.
|
|
53
|
+
*/
|
|
54
|
+
pointersOnly?: boolean;
|
|
32
55
|
}, deps: ContextWriterDeps): ContextWriteResult;
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.CONTEXT_END_MARKER = exports.CONTEXT_START_MARKER = void 0;
|
|
36
|
+
exports.POINTERS_END_MARKER = exports.POINTERS_START_MARKER = exports.CONTEXT_END_MARKER = exports.CONTEXT_START_MARKER = void 0;
|
|
37
37
|
exports.writeContext = writeContext;
|
|
38
38
|
/**
|
|
39
39
|
* pucc-int-01 (ADR-0032 / ADR-0028) — container context writer.
|
|
@@ -68,8 +68,40 @@ exports.writeContext = writeContext;
|
|
|
68
68
|
const path = __importStar(require("path"));
|
|
69
69
|
const bundleInstaller_1 = require("./bundleInstaller");
|
|
70
70
|
const workGraphPointer_1 = require("./workGraphPointer");
|
|
71
|
+
const memoryPointer_1 = require("./memoryPointer");
|
|
71
72
|
exports.CONTEXT_START_MARKER = '<!-- HIVEOP:CONTEXT:START -->';
|
|
72
73
|
exports.CONTEXT_END_MARKER = '<!-- HIVEOP:CONTEXT:END -->';
|
|
74
|
+
/*
|
|
75
|
+
wmr-07 — an inner fence around the standing pointers (work graph, workspace
|
|
76
|
+
memory), INSIDE the managed region.
|
|
77
|
+
|
|
78
|
+
The bridge refreshes those pointers when a session opens, because arena only
|
|
79
|
+
pushes a context-manifest when the pilot has files selected — so waiting for a
|
|
80
|
+
push meant the pointers never arrived at all for most workspaces. But the
|
|
81
|
+
region also holds that pushed file content, and a refresh that rewrote the
|
|
82
|
+
whole region would eat it. This fence is what lets the two be updated
|
|
83
|
+
independently: pointers replaced in place, file content left exactly as it is.
|
|
84
|
+
|
|
85
|
+
A region written before this fence existed has no markers; it is then treated
|
|
86
|
+
as file content and the pointer block is inserted above it, which is the
|
|
87
|
+
correct reading of what it contained.
|
|
88
|
+
*/
|
|
89
|
+
exports.POINTERS_START_MARKER = '<!-- HIVEOP:POINTERS:START -->';
|
|
90
|
+
exports.POINTERS_END_MARKER = '<!-- HIVEOP:POINTERS:END -->';
|
|
91
|
+
/** Split a region body into its pointer block and everything else. */
|
|
92
|
+
function splitRegion(body) {
|
|
93
|
+
const start = body.indexOf(exports.POINTERS_START_MARKER);
|
|
94
|
+
const end = body.indexOf(exports.POINTERS_END_MARKER);
|
|
95
|
+
if (start === -1 || end === -1 || end < start)
|
|
96
|
+
return { pointers: '', rest: body.trim() };
|
|
97
|
+
const rest = (body.slice(0, start) + body.slice(end + exports.POINTERS_END_MARKER.length)).trim();
|
|
98
|
+
return { pointers: body.slice(start, end + exports.POINTERS_END_MARKER.length), rest };
|
|
99
|
+
}
|
|
100
|
+
function pointerBlock(sections) {
|
|
101
|
+
if (sections.length === 0)
|
|
102
|
+
return '';
|
|
103
|
+
return `${exports.POINTERS_START_MARKER}\n${sections.join('\n\n')}\n${exports.POINTERS_END_MARKER}`;
|
|
104
|
+
}
|
|
73
105
|
/** Build the fenced region block for a non-empty body. */
|
|
74
106
|
function regionBlock(content) {
|
|
75
107
|
// Normalize only the region's own trailing newlines so the END marker sits on
|
|
@@ -100,10 +132,34 @@ function writeContext(input, deps) {
|
|
|
100
132
|
// appears — pointing at unregistered MCP tools is worse than silence). When
|
|
101
133
|
// present, the pointer alone is reason enough to keep the region — it does
|
|
102
134
|
// NOT get removed just because there is no pushed file content this turn.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
135
|
+
//
|
|
136
|
+
// wmr-01 adds the memory pointer beside it under the same rule. Both are
|
|
137
|
+
// assembled as a list of present sections so the joining logic states the
|
|
138
|
+
// shape once — two independent flags would otherwise need four branches to
|
|
139
|
+
// get the blank lines right, and the empty-content case is the one that
|
|
140
|
+
// decides whether the region survives at all.
|
|
141
|
+
const existingRegion = (() => {
|
|
142
|
+
if (!existing)
|
|
143
|
+
return '';
|
|
144
|
+
const s0 = existing.indexOf(exports.CONTEXT_START_MARKER);
|
|
145
|
+
const e0 = existing.indexOf(exports.CONTEXT_END_MARKER);
|
|
146
|
+
if (s0 === -1 || e0 === -1 || e0 < s0)
|
|
147
|
+
return '';
|
|
148
|
+
return existing.slice(s0 + exports.CONTEXT_START_MARKER.length, e0).trim();
|
|
149
|
+
})();
|
|
150
|
+
const pointers = [];
|
|
151
|
+
if (input.workGraphAvailable)
|
|
152
|
+
pointers.push(workGraphPointer_1.WORK_GRAPH_POINTER);
|
|
153
|
+
if (input.memoryAvailable)
|
|
154
|
+
pointers.push((0, memoryPointer_1.memorySection)(input.memoryBrief));
|
|
155
|
+
// wmr-07: a pointers-only refresh keeps the region's existing file content
|
|
156
|
+
// rather than replacing it with the (absent) `content` argument.
|
|
157
|
+
const fileContent = input.pointersOnly
|
|
158
|
+
? splitRegion(existingRegion).rest
|
|
159
|
+
: (input.content ?? '').trim();
|
|
160
|
+
const block = pointerBlock(pointers);
|
|
161
|
+
const parts = [block, fileContent].filter((part) => part.length > 0);
|
|
162
|
+
const content = parts.join('\n\n');
|
|
107
163
|
const isEmpty = content.trim().length === 0;
|
|
108
164
|
// Locate any existing managed region.
|
|
109
165
|
const startIdx = existing ? existing.indexOf(exports.CONTEXT_START_MARKER) : -1;
|