@naswerks/periscope 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which command the binary was asked for.
|
|
3
|
+
*
|
|
4
|
+
* Extracted out of the composition root so it can be called without starting a host, the same
|
|
5
|
+
* reason, and the same shape, as `workspaces.ts`. `bin/periscope.ts` calls `main()` on load, so
|
|
6
|
+
* importing it to test one function starts a host, opens a socket and spawns nothing useful. The
|
|
7
|
+
* fix is to split the function out; it is not to add a startup guard to the entry point, because
|
|
8
|
+
* changing production behaviour to make a test possible is how a suite starts describing a system
|
|
9
|
+
* nobody ships.
|
|
10
|
+
*
|
|
11
|
+
* The parser is deliberately tiny and not an option library. This package's direct dependencies
|
|
12
|
+
* are the agent SDK with its peer closure, `ws` and `zod`; a CLI framework for a handful of verbs
|
|
13
|
+
* would be a new dependency in a package whose boundary claims are auditable from one directory.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Read the command from an argv tail (`process.argv.slice(2)`).
|
|
17
|
+
*
|
|
18
|
+
* No arguments means `serve`, and that is a contract rather than a preference: a supervisor or
|
|
19
|
+
* container starts this binary with no arguments, so a bare invocation is the daemon.
|
|
20
|
+
*
|
|
21
|
+
* An unrecognised first argument is `unknown`, never `serve`. Falling back to the default would
|
|
22
|
+
* mean `periscope logn` silently starts a host: the operator believes they are signing in, the
|
|
23
|
+
* process dials out and starts accepting sessions, and the mistake surfaces much later as an empty
|
|
24
|
+
* token cache. A typo must fail loudly at the only moment it is cheap to fix.
|
|
25
|
+
*/
|
|
26
|
+
export function readCommand(argv) {
|
|
27
|
+
const first = argv[0];
|
|
28
|
+
if (first === undefined || first === '')
|
|
29
|
+
return { kind: 'serve' };
|
|
30
|
+
switch (first) {
|
|
31
|
+
case 'serve':
|
|
32
|
+
return { kind: 'serve' };
|
|
33
|
+
case 'login':
|
|
34
|
+
return { kind: 'login' };
|
|
35
|
+
case 'pair':
|
|
36
|
+
return readPairArguments(argv.slice(1));
|
|
37
|
+
case 'config': {
|
|
38
|
+
if (argv[1] === '--unset') {
|
|
39
|
+
const key = argv[2];
|
|
40
|
+
return {
|
|
41
|
+
kind: 'config',
|
|
42
|
+
key: key === undefined || key === '' ? null : key,
|
|
43
|
+
value: null,
|
|
44
|
+
unset: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const key = argv[1];
|
|
48
|
+
const value = argv[2];
|
|
49
|
+
return {
|
|
50
|
+
kind: 'config',
|
|
51
|
+
key: key === undefined || key === '' ? null : key,
|
|
52
|
+
value: value === undefined || value === '' ? null : value,
|
|
53
|
+
unset: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case 'version':
|
|
57
|
+
case '--version':
|
|
58
|
+
case '-v':
|
|
59
|
+
return { kind: 'version' };
|
|
60
|
+
case 'status':
|
|
61
|
+
return { kind: 'status' };
|
|
62
|
+
case 'help':
|
|
63
|
+
case '--help':
|
|
64
|
+
case '-h':
|
|
65
|
+
return { kind: 'help' };
|
|
66
|
+
default:
|
|
67
|
+
return { kind: 'unknown', name: first };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** The two flags `pair` takes. A closed set: anything else starting with `--` is a problem, never the code. */
|
|
71
|
+
const PAIR_FLAGS = ['--controller', '--label'];
|
|
72
|
+
/**
|
|
73
|
+
* `pair`'s tail: one positional (the code) and two optional flags, each as `--flag value` or
|
|
74
|
+
* `--flag=value`. Hand-rolled for the same reason the whole parser is: two flags do not earn an
|
|
75
|
+
* option library. An unknown `--x` is a problem rather than the code, so a typo'd flag cannot be
|
|
76
|
+
* sent to the controller as a pair code and refused there with the wrong message.
|
|
77
|
+
*/
|
|
78
|
+
function readPairArguments(rest) {
|
|
79
|
+
let code = null;
|
|
80
|
+
let controller = null;
|
|
81
|
+
let label = null;
|
|
82
|
+
let problem = null;
|
|
83
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
84
|
+
const argument = rest[index] ?? '';
|
|
85
|
+
if (argument.startsWith('--')) {
|
|
86
|
+
const equals = argument.indexOf('=');
|
|
87
|
+
const name = equals === -1 ? argument : argument.slice(0, equals);
|
|
88
|
+
if (!PAIR_FLAGS.includes(name)) {
|
|
89
|
+
problem ??= `unknown option '${name}'`;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let value;
|
|
93
|
+
if (equals === -1) {
|
|
94
|
+
value = rest[index + 1];
|
|
95
|
+
index += 1;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
value = argument.slice(equals + 1);
|
|
99
|
+
}
|
|
100
|
+
if (value === undefined || value === '') {
|
|
101
|
+
problem ??= `${name} needs a value`;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (name === '--controller')
|
|
105
|
+
controller = value;
|
|
106
|
+
else
|
|
107
|
+
label = value;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (argument === '')
|
|
111
|
+
continue;
|
|
112
|
+
if (code === null)
|
|
113
|
+
code = argument;
|
|
114
|
+
else
|
|
115
|
+
problem ??= `unexpected argument '${argument}'`;
|
|
116
|
+
}
|
|
117
|
+
return { kind: 'pair', code, controller, label, problem };
|
|
118
|
+
}
|
|
119
|
+
/** What `help` prints. Kept beside the parser so a new verb cannot be added without a line here. */
|
|
120
|
+
export const USAGE = [
|
|
121
|
+
'periscope — hosts Claude Code sessions for a remote controller.',
|
|
122
|
+
'',
|
|
123
|
+
'Usage:',
|
|
124
|
+
' periscope [serve] Dial the controller and serve sessions (the default).',
|
|
125
|
+
' periscope login Sign in and write the token cache this host presents.',
|
|
126
|
+
' periscope pair <code> [--controller <origin>] [--label <name>]',
|
|
127
|
+
" Trade a pair code for this machine's durable credential. The controller",
|
|
128
|
+
' origin names where to redeem and writes the link URLs to the config file;',
|
|
129
|
+
' the label is what the controller lists this machine as.',
|
|
130
|
+
' periscope config Show the config file and every value in it.',
|
|
131
|
+
' periscope config <key> [value] Read one config value, or write it.',
|
|
132
|
+
' periscope config --unset <key> Remove one config value from the file.',
|
|
133
|
+
' periscope status Say what this host is: its link, credential, ids, workspace and where',
|
|
134
|
+
' each setting came from. Reads what serve left behind; never dials.',
|
|
135
|
+
' periscope version Print the package version.',
|
|
136
|
+
' periscope help Show this.',
|
|
137
|
+
'',
|
|
138
|
+
'Configuration is read from the environment first, then the config file (the environment wins);',
|
|
139
|
+
'see README.md.',
|
|
140
|
+
].join('\n');
|
|
141
|
+
//# sourceMappingURL=command.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** What one invocation produced: the lines to print, and whether it succeeded. */
|
|
2
|
+
export interface ConfigOutcome {
|
|
3
|
+
readonly ok: boolean;
|
|
4
|
+
readonly lines: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function runConfig(key: string | null, value: string | null, env: NodeJS.ProcessEnv, unset?: boolean): ConfigOutcome;
|
|
7
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `periscope config` — read or write the config file the daemon falls back to.
|
|
3
|
+
*
|
|
4
|
+
* Why this is a command: the binary reads its configuration from the environment first and the
|
|
5
|
+
* file second (every consuming verb: `serve`, `login`, `pair`), and nothing else in this package
|
|
6
|
+
* ever writes that file. Without it, a user installing the host on a machine with no UI has
|
|
7
|
+
* exactly one configuration surface, env vars at process start, which is a supervisor's surface,
|
|
8
|
+
* not a person's. This verb is the person's.
|
|
9
|
+
*
|
|
10
|
+
* It orchestrates only. The filesystem work lives in `host/config-file.ts`, because `src/host/`
|
|
11
|
+
* is the one directory allowed to touch the machine; this module composes that seam and formats
|
|
12
|
+
* lines, exactly the split `login` and `pair` use.
|
|
13
|
+
*
|
|
14
|
+
* The output says what wins. Every listing line marks a value the environment currently
|
|
15
|
+
* overrides, because "set it and nothing changed" with a stale env var standing in front of the
|
|
16
|
+
* file is otherwise confusing; the rule is env-wins and the listing repeats it where the operator
|
|
17
|
+
* is looking.
|
|
18
|
+
*/
|
|
19
|
+
import { CONFIG_KEYS, configFilePath, readConfigFile, writeConfigEntries, writeConfigEntry, } from '../host/config-file.js';
|
|
20
|
+
import { configValueProblem } from './reconfigure.js';
|
|
21
|
+
export function runConfig(key, value, env, unset = false) {
|
|
22
|
+
const path = configFilePath(env);
|
|
23
|
+
if (path === null) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
lines: ['there is nowhere to keep a config file — no home directory and no PERISCOPE_CONFIG_DIR'],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (unset) {
|
|
30
|
+
if (key === null)
|
|
31
|
+
return { ok: false, lines: ['usage: periscope config --unset <key>'] };
|
|
32
|
+
const reading = readConfigFile(env);
|
|
33
|
+
if (reading.problem !== null)
|
|
34
|
+
return { ok: false, lines: [reading.problem] };
|
|
35
|
+
if (reading.values[key] === undefined)
|
|
36
|
+
return { ok: true, lines: [`${key} is not set in ${path}`] };
|
|
37
|
+
const problem = writeConfigEntries(env, [{ key, value: null }]);
|
|
38
|
+
if (problem !== null)
|
|
39
|
+
return { ok: false, lines: [problem] };
|
|
40
|
+
return { ok: true, lines: [`${key} removed from ${path}`] };
|
|
41
|
+
}
|
|
42
|
+
if (key === null) {
|
|
43
|
+
const reading = readConfigFile(env);
|
|
44
|
+
if (reading.problem !== null)
|
|
45
|
+
return { ok: false, lines: [reading.problem] };
|
|
46
|
+
const entries = Object.entries(reading.values);
|
|
47
|
+
const lines = [
|
|
48
|
+
`config file: ${path}`,
|
|
49
|
+
...(entries.length === 0 ? ['(empty — set a value with: periscope config <key> <value>)'] : []),
|
|
50
|
+
...entries.map(([name, stored]) => {
|
|
51
|
+
const fromEnv = env[name];
|
|
52
|
+
const overridden = fromEnv !== undefined && fromEnv !== '' ? ' (currently overridden by the environment)' : '';
|
|
53
|
+
return ` ${name} = ${stored}${overridden}`;
|
|
54
|
+
}),
|
|
55
|
+
`keys this host reads: ${CONFIG_KEYS.join(', ')}`,
|
|
56
|
+
];
|
|
57
|
+
return { ok: true, lines };
|
|
58
|
+
}
|
|
59
|
+
if (value === null) {
|
|
60
|
+
const reading = readConfigFile(env);
|
|
61
|
+
if (reading.problem !== null)
|
|
62
|
+
return { ok: false, lines: [reading.problem] };
|
|
63
|
+
const stored = reading.values[key];
|
|
64
|
+
if (stored === undefined) {
|
|
65
|
+
return { ok: false, lines: [`${key} is not set in ${path}`] };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, lines: [stored] };
|
|
68
|
+
}
|
|
69
|
+
// The same screen the wire runs on `host_configure`, before anything is written: a value the
|
|
70
|
+
// daemon would refuse at its next start is refused here, where the person typing it is looking.
|
|
71
|
+
const screened = configValueProblem(key, value);
|
|
72
|
+
if (screened !== null)
|
|
73
|
+
return { ok: false, lines: [screened] };
|
|
74
|
+
const problem = writeConfigEntry(env, key, value);
|
|
75
|
+
if (problem !== null)
|
|
76
|
+
return { ok: false, lines: [problem] };
|
|
77
|
+
const fromEnv = env[key];
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
lines: [
|
|
81
|
+
`${key} written to ${path}`,
|
|
82
|
+
...(fromEnv !== undefined && fromEnv !== ''
|
|
83
|
+
? [
|
|
84
|
+
`note: ${key} is currently set in the environment, and the environment wins — the file value is a fallback`,
|
|
85
|
+
]
|
|
86
|
+
: []),
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `periscope login` — acquire a token interactively and write it to the cache the daemon reads.
|
|
3
|
+
*
|
|
4
|
+
* Why this is a command rather than something the daemon does: `bin/serve.ts` presents a
|
|
5
|
+
* token that is already there; there is no interactive flow in the shipped daemon, deliberately,
|
|
6
|
+
* because a host meant to run unattended must not block on a browser at start-up. So the
|
|
7
|
+
* interactive half is a separate act, run once by a person, and the daemon is unchanged by this
|
|
8
|
+
* file existing.
|
|
9
|
+
*
|
|
10
|
+
* It writes the same cache the daemon reads, derived from the same call. `tokenCachePath(env)` is
|
|
11
|
+
* the one source for the location, and `credentialPaths(env)`, which the gate uses to build its
|
|
12
|
+
* protected set, derives from it too. Nothing here may compute a path of its own, or the thing the
|
|
13
|
+
* gate refuses to let an agent read and the thing this writes could drift apart.
|
|
14
|
+
*
|
|
15
|
+
* The flow is derived from config, never chosen here. `protocolFor(config)` picks loopback or
|
|
16
|
+
* device-code, and the daemon's credential calls the same function to decide what it expects, so a
|
|
17
|
+
* cache written under one protocol can never be read under the other. (`TokenCredential` discards a
|
|
18
|
+
* cache whose protocol does not match, so getting this wrong would look like "signing in did
|
|
19
|
+
* nothing".) Setting `PERISCOPE_IDENTITY_DEVICE_CODE=1` moves both ends together, which is the
|
|
20
|
+
* property that makes it safe to expose as one environment variable.
|
|
21
|
+
*/
|
|
22
|
+
import type { Result } from '../core/result.js';
|
|
23
|
+
import type { CachedTokens, TokenStore } from '../identity/index.js';
|
|
24
|
+
import { signIn, signInWithDeviceCode } from '../host/index.js';
|
|
25
|
+
/** The edges, injected so the whole command is testable without a browser, a socket or a clock. */
|
|
26
|
+
export interface LoginDeps {
|
|
27
|
+
/** Where operator-facing instructions go. Defaults to stdout. */
|
|
28
|
+
readonly write?: (line: string) => void;
|
|
29
|
+
/** Replaced in tests; the real ones talk to the provider. */
|
|
30
|
+
readonly signIn?: typeof signIn;
|
|
31
|
+
readonly signInWithDeviceCode?: typeof signInWithDeviceCode;
|
|
32
|
+
/** Replaced in tests so nothing touches the real cache path. Defaults to the file cache at `path`. */
|
|
33
|
+
readonly store?: (path: string) => TokenStore;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a paired credential is present at `path`. Replaced in tests. The daemon prefers a
|
|
36
|
+
* paired credential over the token this verb writes, so a signed-in operator on a paired box is
|
|
37
|
+
* told the token will not be the one presented.
|
|
38
|
+
*/
|
|
39
|
+
readonly pairedCredentialPresent?: (path: string) => boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Run the login command.
|
|
43
|
+
*
|
|
44
|
+
* Returns a `Result` rather than exiting, so the caller owns the process and this stays callable
|
|
45
|
+
* from a test. Every failure is a named refusal, the same posture the rest of the package holds.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runLogin(env: NodeJS.ProcessEnv, deps?: LoginDeps): Promise<Result<CachedTokens>>;
|
|
48
|
+
//# sourceMappingURL=login.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ok, refuse } from '../core/result.js';
|
|
2
|
+
import { isAuthFlowBlocked, readIdentityConfig } from '../identity/index.js';
|
|
3
|
+
import { FilePairedCredential, FileTokenCache, pairedCredentialPath, protocolFor, signIn, signInWithDeviceCode, tokenCachePath, } from '../host/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Run the login command.
|
|
6
|
+
*
|
|
7
|
+
* Returns a `Result` rather than exiting, so the caller owns the process and this stays callable
|
|
8
|
+
* from a test. Every failure is a named refusal, the same posture the rest of the package holds.
|
|
9
|
+
*/
|
|
10
|
+
export async function runLogin(env, deps = {}) {
|
|
11
|
+
const write = deps.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
12
|
+
// Said once, on success: the daemon prefers a paired credential over the token this verb writes,
|
|
13
|
+
// so an operator signing in on a paired box is told the token will not be the one presented.
|
|
14
|
+
const pairedPath = pairedCredentialPath(env);
|
|
15
|
+
const pairedPresent = deps.pairedCredentialPresent ?? ((path) => new FilePairedCredential(path).read().ok);
|
|
16
|
+
const pairedNotice = pairedPath !== null && pairedPresent(pairedPath)
|
|
17
|
+
? `note: this machine holds a paired credential at ${pairedPath}, and the daemon presents that on ` +
|
|
18
|
+
'every dial in preference to this token'
|
|
19
|
+
: null;
|
|
20
|
+
// The config module's own read and its own refusal, restated nowhere.
|
|
21
|
+
//
|
|
22
|
+
// Two reasons, and the second is enforced. (1) "Configured" must mean the identical thing here and
|
|
23
|
+
// in the daemon; an operator who can sign in but whose host then refuses to start is the worst
|
|
24
|
+
// outcome available. (2) `pins/identity-generic.test.ts` asserts that exactly one shipped file
|
|
25
|
+
// names the authority environment variable, because a second mention is a second answer to "which
|
|
26
|
+
// provider is this host talking to". Naming those variables here, even in a comment or a message
|
|
27
|
+
// string, since the pin reads source text, trips it, and it is right to: the fix is to carry the
|
|
28
|
+
// one refusal that already exists rather than write a second copy that can drift.
|
|
29
|
+
//
|
|
30
|
+
// Not a default, a refusal. There is no fallback issuer and no implicit tenant: signing in
|
|
31
|
+
// "somewhere" would mint a token this controller cannot validate, and the failure would surface
|
|
32
|
+
// much later as a 401 on the upgrade with nothing naming the cause.
|
|
33
|
+
const read = readIdentityConfig(env);
|
|
34
|
+
if (!read.ok)
|
|
35
|
+
return refuse(read.refusal.reason, read.refusal.detail);
|
|
36
|
+
const config = read.value;
|
|
37
|
+
const cachePath = tokenCachePath(env);
|
|
38
|
+
if (cachePath === null) {
|
|
39
|
+
return refuse('identity-config-invalid', 'identity is configured but there is nowhere to keep the token cache — set PERISCOPE_CONFIG_DIR');
|
|
40
|
+
}
|
|
41
|
+
const store = (deps.store ?? ((path) => new FileTokenCache(path)))(cachePath);
|
|
42
|
+
const protocol = protocolFor(config);
|
|
43
|
+
if (protocol === 'device-code') {
|
|
44
|
+
// The headless flow, and it must stay reachable without a redirect port. This is the flow for
|
|
45
|
+
// a box with no browser, such as a server or a container. It takes no listener and binds
|
|
46
|
+
// nothing.
|
|
47
|
+
const run = deps.signInWithDeviceCode ?? signInWithDeviceCode;
|
|
48
|
+
const result = await run(config, store, (instruction) => {
|
|
49
|
+
write(`open ${instruction.verificationUri} and enter the code: ${instruction.userCode}`);
|
|
50
|
+
});
|
|
51
|
+
return finish(result, cachePath, write, pairedNotice);
|
|
52
|
+
}
|
|
53
|
+
// Loopback + PKCE: the primary flow. `signIn` binds an ephemeral port and closes it on every path;
|
|
54
|
+
// its default `present` prints the URL, which is what works over SSH.
|
|
55
|
+
const run = deps.signIn ?? signIn;
|
|
56
|
+
const result = await run(config, store, {
|
|
57
|
+
present: (url) => write(`open this to sign in:\n${url}`),
|
|
58
|
+
});
|
|
59
|
+
return finish(result, cachePath, write, pairedNotice);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Report the outcome once, in the operator's words.
|
|
63
|
+
*
|
|
64
|
+
* A policy-blocked flow is surfaced with its do-not-retry rule attached. A provider policy blocking
|
|
65
|
+
* the device-code flow refuses identically every time, so an operator who reads a bare failure will
|
|
66
|
+
* re-run it, and the second attempt costs the same and fails the same way.
|
|
67
|
+
*/
|
|
68
|
+
function finish(result, cachePath, write, pairedNotice) {
|
|
69
|
+
if (!result.ok) {
|
|
70
|
+
write(`sign-in failed: ${result.refusal.reason} — ${result.refusal.detail}`);
|
|
71
|
+
if (isAuthFlowBlocked(result.refusal.detail)) {
|
|
72
|
+
write('do not re-run the device code flow: the same policy will block it, every time. ' +
|
|
73
|
+
'Use the loopback flow (unset PERISCOPE_IDENTITY_DEVICE_CODE) or have the policy changed.');
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
write(`signed in; token cache written to ${cachePath}`);
|
|
78
|
+
if (pairedNotice !== null)
|
|
79
|
+
write(pairedNotice);
|
|
80
|
+
return ok(result.value);
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { runConfig } from './config.js';
|
|
2
|
+
import { runLogin } from './login.js';
|
|
3
|
+
import { runPair } from './pair.js';
|
|
4
|
+
import type { ServeOutcome, ServeViews } from './serve.js';
|
|
5
|
+
import { runStatus } from './status.js';
|
|
6
|
+
/** The process edges. `processIo()` is the real set; a test supplies recording ones. */
|
|
7
|
+
export interface Io {
|
|
8
|
+
/** One stdout line, without its newline. */
|
|
9
|
+
readonly stdout: (line: string) => void;
|
|
10
|
+
/** One stderr line, without its newline. */
|
|
11
|
+
readonly stderr: (line: string) => void;
|
|
12
|
+
readonly setExitCode: (code: number) => void;
|
|
13
|
+
/** The verbs, replaceable so the dispatch is testable without a provider, a controller or a host. */
|
|
14
|
+
readonly runLogin?: typeof runLogin;
|
|
15
|
+
readonly runPair?: typeof runPair;
|
|
16
|
+
readonly runConfig?: typeof runConfig;
|
|
17
|
+
readonly serve?: (views: ServeViews) => ServeOutcome;
|
|
18
|
+
readonly runStatus?: typeof runStatus;
|
|
19
|
+
/** The package version, replaceable so the dispatch test does not read the manifest. */
|
|
20
|
+
readonly version?: () => string;
|
|
21
|
+
}
|
|
22
|
+
/** The real process edges: stdout, stderr and the exit code. The verbs take their defaults. */
|
|
23
|
+
export declare function processIo(): Io;
|
|
24
|
+
/**
|
|
25
|
+
* The command dispatch.
|
|
26
|
+
*
|
|
27
|
+
* `serve` runs synchronously, which is the path every supervisor takes. `login` and `pair` are the
|
|
28
|
+
* asynchronous verbs; each returns its own promise so a caller can await the outcome, and nothing
|
|
29
|
+
* about the daemon's start-up ordering depends on them. `raw` is the process environment as
|
|
30
|
+
* received; the merged view, where the config file fills absences, is derived per verb.
|
|
31
|
+
*/
|
|
32
|
+
export declare function main(argv: readonly string[], raw: NodeJS.ProcessEnv, io: Io): Promise<void> | void;
|
|
33
|
+
//# sourceMappingURL=main.d.ts.map
|
package/dist/bin/main.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composition root, and the only place this package reads the environment.
|
|
3
|
+
*
|
|
4
|
+
* Everything below takes its configuration as arguments. That is what makes the rest of the
|
|
5
|
+
* package testable without a process, and it is why "where does this value come from?" has one
|
|
6
|
+
* answer instead of one per module. `main` itself takes the environment and the process edges as
|
|
7
|
+
* arguments for the same reason; `bin/periscope.ts` is the one line that hands it the real ones.
|
|
8
|
+
*/
|
|
9
|
+
import { readConfigFile, withConfigFallback } from '../host/config-file.js';
|
|
10
|
+
import { packageVersion } from '../host/package-facts.js';
|
|
11
|
+
import { USAGE, readCommand } from './command.js';
|
|
12
|
+
import { runConfig } from './config.js';
|
|
13
|
+
import { runLogin } from './login.js';
|
|
14
|
+
import { runPair } from './pair.js';
|
|
15
|
+
import { runServe } from './serve.js';
|
|
16
|
+
import { runStatus } from './status.js';
|
|
17
|
+
/** The real process edges: stdout, stderr and the exit code. The verbs take their defaults. */
|
|
18
|
+
export function processIo() {
|
|
19
|
+
return {
|
|
20
|
+
stdout: (line) => {
|
|
21
|
+
process.stdout.write(`${line}\n`);
|
|
22
|
+
},
|
|
23
|
+
stderr: (line) => {
|
|
24
|
+
process.stderr.write(`${line}\n`);
|
|
25
|
+
},
|
|
26
|
+
setExitCode: (code) => {
|
|
27
|
+
process.exitCode = code;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The config file fills absences, and only absences, for every verb that consumes configuration
|
|
33
|
+
* (`serve`, `login`, `pair`), which is what makes USAGE's unconditional env-first-then-file
|
|
34
|
+
* sentence true. The merged view is consulted for the closed key set only, so a deployment that
|
|
35
|
+
* sets env vars is configured by them alone, and nothing outside that set (identity configuration,
|
|
36
|
+
* the config dir itself) can arrive from a file. A file that exists and cannot be used is fatal
|
|
37
|
+
* rather than silently empty: a corrupt file must not impersonate a deliberate absence (the paired
|
|
38
|
+
* credential takes the same posture, for the same reason).
|
|
39
|
+
*
|
|
40
|
+
* `pair` belongs on that list: `PERISCOPE_DECISION_URL` is a legal config key and is exactly what
|
|
41
|
+
* `redemptionUrl` derives the redemption door from, so a config-file-only machine handed raw env
|
|
42
|
+
* would answer "nowhere to redeem the code" at the first step of the very flow the config verb was
|
|
43
|
+
* built for. `login` reads no config key today; it rides the merged view so the help text's claim
|
|
44
|
+
* is true by wiring rather than by coincidence.
|
|
45
|
+
*/
|
|
46
|
+
function environmentWithConfigFile(raw) {
|
|
47
|
+
const fileConfig = readConfigFile(raw);
|
|
48
|
+
if (fileConfig.problem !== null)
|
|
49
|
+
return fileConfig.problem;
|
|
50
|
+
return withConfigFallback(raw, fileConfig.values);
|
|
51
|
+
}
|
|
52
|
+
/** `error` as one line, for a verb that threw instead of returning. */
|
|
53
|
+
function describe(error) {
|
|
54
|
+
return error instanceof Error ? error.message : String(error);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The command dispatch.
|
|
58
|
+
*
|
|
59
|
+
* `serve` runs synchronously, which is the path every supervisor takes. `login` and `pair` are the
|
|
60
|
+
* asynchronous verbs; each returns its own promise so a caller can await the outcome, and nothing
|
|
61
|
+
* about the daemon's start-up ordering depends on them. `raw` is the process environment as
|
|
62
|
+
* received; the merged view, where the config file fills absences, is derived per verb.
|
|
63
|
+
*/
|
|
64
|
+
export function main(argv, raw, io) {
|
|
65
|
+
const command = readCommand(argv);
|
|
66
|
+
if (command.kind === 'help') {
|
|
67
|
+
io.stdout(USAGE);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (command.kind === 'version') {
|
|
71
|
+
io.stdout((io.version ?? packageVersion)());
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (command.kind === 'unknown') {
|
|
75
|
+
// Named rather than defaulted to `serve` (see `readCommand`): a typo that silently starts a host
|
|
76
|
+
// is discovered much later, as an empty token cache.
|
|
77
|
+
io.stderr(`periscope: unknown command '${command.name}'\n\n${USAGE}`);
|
|
78
|
+
io.setExitCode(1);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (command.kind === 'login') {
|
|
82
|
+
const env = environmentWithConfigFile(raw);
|
|
83
|
+
if (typeof env === 'string') {
|
|
84
|
+
io.stderr(`periscope: ${env}`);
|
|
85
|
+
io.setExitCode(1);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// The interactive half is a separate act by design; the daemon never signs anyone in.
|
|
89
|
+
return (io.runLogin ?? runLogin)(env).then((result) => {
|
|
90
|
+
if (!result.ok) {
|
|
91
|
+
io.stderr(`periscope: login failed - ${result.refusal.reason} - ${result.refusal.detail}`);
|
|
92
|
+
io.setExitCode(1);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
// `runLogin` returns refusals rather than throwing, so this branch is for the failure it did
|
|
96
|
+
// not model. Without it a throw here would be an unhandled rejection followed by exit 0: a
|
|
97
|
+
// sign-in that crashed reporting success to whoever scripted it.
|
|
98
|
+
(error) => {
|
|
99
|
+
io.stderr(`periscope: login failed - ${describe(error)}`);
|
|
100
|
+
io.setExitCode(1);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (command.kind === 'status') {
|
|
104
|
+
const outcome = (io.runStatus ?? runStatus)({ raw, merged: environmentWithConfigFile(raw) });
|
|
105
|
+
const sink = outcome.ok ? io.stdout : io.stderr;
|
|
106
|
+
for (const line of outcome.lines)
|
|
107
|
+
sink(outcome.ok ? line : `periscope: ${line}`);
|
|
108
|
+
if (!outcome.ok)
|
|
109
|
+
io.setExitCode(1);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (command.kind === 'config') {
|
|
113
|
+
// Synchronous by nature: one file read or one file write, no network, no process.
|
|
114
|
+
//
|
|
115
|
+
// Raw env, deliberately. This is the one verb that must never see the merged view, twice over:
|
|
116
|
+
// its "currently overridden by the environment" marker exists to distinguish env from file,
|
|
117
|
+
// and a merged env would mark every file value as overridden by itself; and this verb is the
|
|
118
|
+
// escape hatch that makes fatal-on-corrupt safe everywhere else. A corrupt file must still
|
|
119
|
+
// leave `periscope config` able to say which file is broken and why, rather than refusing
|
|
120
|
+
// before the verb runs.
|
|
121
|
+
const outcome = (io.runConfig ?? runConfig)(command.key, command.value, raw, command.unset);
|
|
122
|
+
const sink = outcome.ok ? io.stdout : io.stderr;
|
|
123
|
+
for (const line of outcome.lines)
|
|
124
|
+
sink(line);
|
|
125
|
+
if (!outcome.ok)
|
|
126
|
+
io.setExitCode(1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (command.kind === 'pair') {
|
|
130
|
+
if (command.problem !== null) {
|
|
131
|
+
io.stderr(`periscope: ${command.problem} - usage: periscope pair <code> [--controller <origin>] [--label <name>]`);
|
|
132
|
+
io.setExitCode(1);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const env = environmentWithConfigFile(raw);
|
|
136
|
+
if (typeof env === 'string') {
|
|
137
|
+
io.stderr(`periscope: ${env}`);
|
|
138
|
+
io.setExitCode(1);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// The same shape as `login`, for the same reasons, including the rejection branch, because a
|
|
142
|
+
// pairing that crashed must not report success to whoever scripted it.
|
|
143
|
+
return (io.runPair ?? runPair)(command.code, env, undefined, {
|
|
144
|
+
controller: command.controller,
|
|
145
|
+
label: command.label,
|
|
146
|
+
}).then((result) => {
|
|
147
|
+
if (!result.ok) {
|
|
148
|
+
io.stderr(`periscope: pair failed - ${result.detail}`);
|
|
149
|
+
io.setExitCode(1);
|
|
150
|
+
}
|
|
151
|
+
}, (error) => {
|
|
152
|
+
io.stderr(`periscope: pair failed - ${describe(error)}`);
|
|
153
|
+
io.setExitCode(1);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// The daemon reads two views: configuration from the merged one, credentials and the session
|
|
157
|
+
// environment from the raw one. `runServe` says which reads which, and reports its own refusals.
|
|
158
|
+
const serve = io.serve ?? ((views) => runServe(views, processServeDeps(io)));
|
|
159
|
+
serve({ raw, merged: environmentWithConfigFile(raw) });
|
|
160
|
+
}
|
|
161
|
+
/** The daemon's edges over a caller-supplied `io`, with the process itself behind exit and signals. */
|
|
162
|
+
function processServeDeps(io) {
|
|
163
|
+
return {
|
|
164
|
+
log: io.stdout,
|
|
165
|
+
stderr: io.stderr,
|
|
166
|
+
setExitCode: io.setExitCode,
|
|
167
|
+
exit: (code) => process.exit(code),
|
|
168
|
+
onSignal: (signal, handler) => {
|
|
169
|
+
process.on(signal, handler);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=main.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PairedCredentialStore } from '../identity/paired-credential.js';
|
|
2
|
+
/** The two flags: null means "not given", and the environment path applies. */
|
|
3
|
+
export interface PairAsk {
|
|
4
|
+
readonly controller?: string | null;
|
|
5
|
+
readonly label?: string | null;
|
|
6
|
+
}
|
|
7
|
+
/** The verb's outcome: plain, CLI-local, and deliberately not the wire's refusal vocabulary.
|
|
8
|
+
* Nothing here crosses the link, so nothing here may widen it. */
|
|
9
|
+
export type PairOutcome = {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly hostId: string;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly detail: string;
|
|
16
|
+
};
|
|
17
|
+
/** The edges, injected so the whole verb is testable without a controller or a disk. */
|
|
18
|
+
export interface PairDeps {
|
|
19
|
+
/** Where operator-facing lines go. Defaults to stdout. */
|
|
20
|
+
readonly write?: (line: string) => void;
|
|
21
|
+
/** The HTTP edge. Defaults to global fetch. */
|
|
22
|
+
readonly transport?: typeof fetch;
|
|
23
|
+
/** Replaced in tests so nothing touches the real credential path. */
|
|
24
|
+
readonly store?: (path: string) => PairedCredentialStore;
|
|
25
|
+
}
|
|
26
|
+
/** Run the pair command. Returns rather than exits, so the caller owns the process. */
|
|
27
|
+
export declare function runPair(code: string | null, env: NodeJS.ProcessEnv, deps?: PairDeps, asked?: PairAsk): Promise<PairOutcome>;
|
|
28
|
+
//# sourceMappingURL=pair.d.ts.map
|