@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.
Files changed (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. 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
@@ -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