@nocville/npc 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,18 +7,22 @@ a hand when it needs you, dozes when idle, and shows what it's doing on its spee
7
7
  desk sign: `Editing src/GameScene.ts`, `Running pnpm test`, `Searching for 'reconnect'`,
8
8
  `Needs your approval`, `Thinking…`. It leaves the office when the session ends.
9
9
 
10
- Requires Node.js 20+. Get an agent key in your Nocville office under **Connect AI coworkers**.
10
+ Requires Node.js 20.12 or newer and a Nocville account.
11
11
 
12
12
  ## Quick start: AI coworkers
13
13
 
14
14
  ```bash
15
- npx @nocville/npc setup
15
+ npx @nocville/npc@latest setup
16
16
  ```
17
17
 
18
- Paste your key at the prompt (input is hidden, so the key never lands in your shell history).
19
- For scripts, pass `--api-key=<your key>` or set `NOCVILLE_API_KEY` instead. That's it. The
20
- command saves your key to `~/.config/nocville/config.json` (readable only by you), then hooks
21
- up whichever agents you have installed:
18
+ Setup opens Nocville in your browser. Sign in, choose your team and office, and click
19
+ **Connect this computer**. If the browser asks for local network access, allow it so
20
+ it can return the connection to the setup command on this computer. Keep an existing
21
+ team member's office open during the first connection so encrypted publishing can
22
+ be enabled automatically.
23
+
24
+ Setup saves the publishing key and verified workspace settings in private files under
25
+ `~/.config/nocville/`, then hooks up whichever agents you have installed:
22
26
 
23
27
  - **Claude Code**: adds hooks to `~/.claude/settings.json`. New sessions show up right away;
24
28
  restart sessions that are already running.
@@ -31,10 +35,10 @@ up whichever agents you have installed:
31
35
  Your existing hooks are left alone, and Nocville's hooks never answer permission prompts or
32
36
  change what your agent does; they only report activity.
33
37
 
34
- Running your own Nocville server? Add `--api-url=https://your-server`. To send sessions to a
35
- map other than your team's default, add `--map="Trading Floor"`.
38
+ Using your team’s own Cloudflare hosting? Add `--api-url=https://your-office-host`.
39
+ Use `setup --reconnect` to choose a different office or renew the connection.
36
40
 
37
- Prefer a global install? Run `npm i -g @nocville/npc`, then `nocville-npc setup --api-key=…`.
41
+ Prefer a global install? Run `npm i -g @nocville/npc`, then `nocville-npc setup`.
38
42
  When setup runs through `npx`, it copies itself to `~/.config/nocville/runtime/` so the hooks
39
43
  keep working after npm clears its cache.
40
44
 
@@ -75,8 +79,11 @@ checks the current folder (`--project=<dir>`, `--name=<session name>` to test ot
75
79
  Gemini CLI and Cursor on their own. For Cursor, start a new chat after setup.
76
80
 
77
81
  `nocville-npc uninstall` removes every Nocville hook. `nocville-npc init` only saves
78
- credentials. Every setup command takes the key from `--api-key`, then `NOCVILLE_API_KEY`, then
79
- the saved config, and otherwise prompts for it (in a terminal) or exits with an error. `nocville-npc doctor` checks connectivity and sends a test push.
82
+ credentials. Cloudflare setup connects through your browser unless a complete encrypted
83
+ workspace is already configured. For automated installations, supply a trusted
84
+ `--workspace=/absolute/path/workspace.json` and `NOCVILLE_API_KEY`. Legacy self-hosted
85
+ servers still accept the hidden key prompt. `nocville-npc doctor` checks connectivity
86
+ and sends a test push.
80
87
 
81
88
  ### What you'll see
82
89
 
@@ -229,19 +236,14 @@ key and connection work. More at [nocville.com](https://nocville.com).
229
236
 
230
237
  ## Encrypted Cloudflare workspaces
231
238
 
232
- Download and verify the producer workspace settings in team privacy settings, then:
233
-
234
- ```sh
235
- nocville-npc init --workspace=/absolute/path/workspace.json
236
- # Supply the agent key through NOCVILLE_API_KEY or the existing init prompt.
237
- nocville-npc system-monitor
238
- ```
239
-
240
- `--workspace`, `NOCVILLE_WORKSPACE`, and the saved workspace path are supported in
241
- that order. Constructor `workspace` overrides them. The first publication displays
242
- a signing-device fingerprint for the team owner to approve. Approval is shared by
243
- the local NPCs using this credential, including coding-agent hooks and watchdogs.
244
- The workspace pins the HTTPS origin and opaque map ID; changing either requires
245
- matching verified workspace settings. The `doctor` command uses encrypted enrollment
246
- and an offline probe for these workspaces. The Node SDK README describes private
247
- profile storage and the encryption boundary.
239
+ Run `npx @nocville/npc@latest setup` to configure encrypted publishing automatically.
240
+ Old keys from the former server deployment cannot publish to encrypted workspaces;
241
+ setup replaces that connection without removing other agents' hooks.
242
+
243
+ For managed automation, `--workspace`, `NOCVILLE_WORKSPACE`, and the saved workspace
244
+ path are supported in that order. Constructor `workspace` overrides them. The
245
+ workspace pins the HTTPS origin, deployment, team root and map ID. Reconnecting to
246
+ the same team cannot silently replace its pinned encryption identity. Local NPCs,
247
+ agent hooks and watchdogs share an enrolled producer profile for the credential.
248
+ The `doctor` command uses encrypted enrollment and an offline probe. The Node SDK
249
+ README describes private profile storage and the encryption boundary.
package/dist/cjs/cli.js CHANGED
@@ -47,9 +47,9 @@ const USAGE = `
47
47
  nocville-npc <command> [options]
48
48
 
49
49
  Quick start (AI coworkers):
50
- nocville-npc setup Paste your agent key (hidden prompt),
50
+ nocville-npc setup Connect your office in the browser,
51
51
  then hook up installed coding agents
52
- (whichever are installed)
52
+ nocville-npc setup --reconnect Choose an office and renew this connection
53
53
  nocville-npc setup --api-key=<agent key> Same, non-interactive
54
54
  nocville-npc uninstall Remove all Nocville hooks
55
55
 
@@ -186,22 +186,27 @@ async function main() {
186
186
  console.log(USAGE);
187
187
  process.exit(1);
188
188
  }
189
- const config = (0, config_js_1.resolveConfig)(parsed.flags);
189
+ let config = (0, config_js_1.resolveConfig)(parsed.flags);
190
190
  const logger = new logger_js_1.Logger('cli', config.debug);
191
191
  // Setup-style commands resolve the key: --api-key > env > saved > hidden prompt.
192
192
  const isAgentSetup = ['claude-code', 'codex', 'gemini', 'cursor'].includes(parsed.command) &&
193
193
  parsed.subcommand === 'setup' &&
194
194
  !parsed.flags['uninstall'];
195
195
  if (isAgentSetup || parsed.command === 'setup') {
196
- if (parsed.command === 'setup') {
197
- const { codexHome } = await Promise.resolve().then(() => __importStar(require('./npcs/codex/defaults.js')));
198
- if (!(0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_os_1.homedir)(), '.claude')) &&
199
- !(0, node_fs_1.existsSync)(codexHome()) &&
200
- !(0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_os_1.homedir)(), '.gemini')) &&
201
- !(0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_os_1.homedir)(), '.cursor'))) {
202
- console.error('Nothing to set up: install Claude Code, Codex, Gemini CLI or Cursor first.');
203
- process.exitCode = 1;
204
- return;
196
+ if (!config.workspace || !config.apiKey.includes('.team_') || parsed.flags['reconnect']) {
197
+ const { connectCloudflareOffice } = await Promise.resolve().then(() => __importStar(require('./core/browser-connect.js')));
198
+ if (await connectCloudflareOffice(config.apiUrl)) {
199
+ const { loadUserConfig } = await Promise.resolve().then(() => __importStar(require('./core/user-config.js')));
200
+ const saved = loadUserConfig();
201
+ for (const [flag, value] of Object.entries({
202
+ 'api-key': saved.apiKey,
203
+ 'api-url': saved.apiUrl,
204
+ map: saved.map,
205
+ workspace: saved.workspace,
206
+ }))
207
+ if (value)
208
+ parsed.flags[flag] = value;
209
+ config = (0, config_js_1.resolveConfig)(parsed.flags);
205
210
  }
206
211
  }
207
212
  const key = await resolveSetupKey(parsed.flags);
@@ -209,6 +214,16 @@ async function main() {
209
214
  return;
210
215
  if (key !== undefined)
211
216
  parsed.flags['api-key'] = key;
217
+ config = (0, config_js_1.resolveConfig)(parsed.flags);
218
+ if (config.workspace) {
219
+ const { saveUserConfig } = await Promise.resolve().then(() => __importStar(require('./core/user-config.js')));
220
+ saveUserConfig({
221
+ workspace: config.workspace,
222
+ apiUrl: config.apiUrl,
223
+ apiKey: config.apiKey,
224
+ map: config.map,
225
+ });
226
+ }
212
227
  }
213
228
  // AI-agent integrations have their own config handling (hooks read the saved config).
214
229
  if (parsed.command === 'claude-code') {
@@ -291,6 +306,10 @@ async function main() {
291
306
  return;
292
307
  }
293
308
  // All other NPC types require api-url and token
309
+ if (!config.workspace &&
310
+ (config.apiKey.includes('.team_') ||
311
+ config.apiUrl.replace(/\/+$/, '') === 'https://nocville.com'))
312
+ throw new Error('Connect this computer to your office first: run npx @nocville/npc@latest setup. Existing legacy keys cannot publish encrypted NPC updates.');
294
313
  if (!config.apiKey) {
295
314
  console.error('Error: no API key — run `nocville-npc init --api-key=<key>`, or pass --api-key / NOCVILLE_API_KEY');
296
315
  process.exit(1);
@@ -639,8 +658,7 @@ async function runSetupAll(flags) {
639
658
  console.log('· Cursor not found (~/.cursor missing) — skipped');
640
659
  }
641
660
  if (!any) {
642
- console.error('Nothing to set up: install Claude Code, Codex, Gemini CLI or Cursor first.');
643
- process.exitCode = 1;
661
+ console.log('No supported coding agents found. You can still run NPC commands on this computer.');
644
662
  }
645
663
  }
646
664
  main().catch((err) => {
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateConnection = validateConnection;
4
+ exports.browserConnection = browserConnection;
5
+ exports.saveBrowserConnection = saveBrowserConnection;
6
+ exports.openConnectionBrowser = openConnectionBrowser;
7
+ exports.connectCloudflareOffice = connectCloudflareOffice;
8
+ const node_http_1 = require("node:http");
9
+ const node_crypto_1 = require("node:crypto");
10
+ const node_child_process_1 = require("node:child_process");
11
+ const node_fs_1 = require("node:fs");
12
+ const node_path_1 = require("node:path");
13
+ const node_1 = require("@nocville/node");
14
+ const user_config_js_1 = require("./user-config.js");
15
+ function validateConnection(input, origin, deploymentId) {
16
+ const value = input;
17
+ const e = value?.workspace?.encryption;
18
+ if (typeof value?.apiKey !== 'string' ||
19
+ !/^nvk_[a-f0-9]{64}\.team_[A-Za-z0-9_-]{1,64}$/.test(value.apiKey) ||
20
+ value.workspace?.apiUrl !== origin ||
21
+ e?.deploymentId !== deploymentId ||
22
+ !/^team_[A-Za-z0-9_-]{1,64}$/.test(e?.teamId ?? '') ||
23
+ !/^map_[A-Za-z0-9_-]{1,128}$/.test(e?.mapId ?? '') ||
24
+ !/^[A-Za-z0-9_-]{43}$/.test(e?.trustedRoot ?? '') ||
25
+ !value.apiKey.endsWith('.' + e.teamId) ||
26
+ e.profileDirectory !== undefined)
27
+ throw new Error('Invalid office connection. Start setup again.');
28
+ return {
29
+ apiKey: value.apiKey,
30
+ workspace: {
31
+ apiUrl: origin,
32
+ encryption: {
33
+ deploymentId,
34
+ teamId: e.teamId,
35
+ mapId: e.mapId,
36
+ trustedRoot: e.trustedRoot,
37
+ },
38
+ },
39
+ };
40
+ }
41
+ /** A one-use loopback receiver. Credentials never appear in a URL or terminal output. */
42
+ async function browserConnection(origin, deploymentId, save, timeoutMs = 300000) {
43
+ const target = new URL(origin);
44
+ if (target.origin !== origin ||
45
+ target.username ||
46
+ target.password ||
47
+ (target.protocol !== 'https:' &&
48
+ !(target.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname))))
49
+ throw new Error('Use an HTTPS office address.');
50
+ const state = (0, node_crypto_1.randomBytes)(32).toString('hex');
51
+ let finish, fail;
52
+ const done = new Promise((resolve, reject) => {
53
+ finish = resolve;
54
+ fail = reject;
55
+ });
56
+ // A timeout may happen while the browser launcher is still opening.
57
+ void done.catch(() => { });
58
+ let consumed = false, port = 0;
59
+ const server = (0, node_http_1.createServer)(async (request, response) => {
60
+ response.setHeader('Cache-Control', 'no-store');
61
+ if (request.headers.host !== `127.0.0.1:${port}` ||
62
+ request.url !== '/complete' ||
63
+ request.headers.origin !== origin) {
64
+ response.writeHead(403).end();
65
+ return;
66
+ }
67
+ response.setHeader('Access-Control-Allow-Origin', origin);
68
+ response.setHeader('Vary', 'Origin');
69
+ if (request.method === 'OPTIONS') {
70
+ response.setHeader('Access-Control-Allow-Methods', 'POST');
71
+ response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
72
+ response.setHeader('Access-Control-Allow-Private-Network', 'true');
73
+ response.writeHead(204).end();
74
+ return;
75
+ }
76
+ if (request.method !== 'POST' ||
77
+ request.headers['content-type']?.split(';')[0] !== 'application/json') {
78
+ response.writeHead(415).end();
79
+ return;
80
+ }
81
+ try {
82
+ let size = 0;
83
+ const chunks = [];
84
+ for await (const chunk of request) {
85
+ size += chunk.length;
86
+ if (size > 16384) {
87
+ response.writeHead(413).end();
88
+ return;
89
+ }
90
+ chunks.push(Buffer.from(chunk));
91
+ }
92
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
93
+ if (body.state !== state || consumed) {
94
+ response.writeHead(403).end();
95
+ return;
96
+ }
97
+ const value = validateConnection(body, origin, deploymentId);
98
+ consumed = true;
99
+ await save(value);
100
+ response.once('finish', finish);
101
+ response.writeHead(200, { 'Content-Type': 'application/json' }).end('{"connected":true}');
102
+ }
103
+ catch {
104
+ response.writeHead(400).end('Could not save this office connection. Start setup again.');
105
+ if (consumed)
106
+ fail(new Error('Could not save the office connection. Start setup again.'));
107
+ }
108
+ });
109
+ server.requestTimeout = 15000;
110
+ server.headersTimeout = 10000;
111
+ await new Promise((resolve, reject) => {
112
+ server.once('error', reject);
113
+ server.listen(0, '127.0.0.1', resolve);
114
+ });
115
+ port = server.address().port;
116
+ const timer = setTimeout(() => fail(new Error('Office connection timed out. Run setup again.')), timeoutMs);
117
+ const url = new URL('/npcs/connect', origin);
118
+ url.searchParams.set('port', String(port));
119
+ url.searchParams.set('state', state);
120
+ return {
121
+ url: url.href,
122
+ done,
123
+ close: () => {
124
+ clearTimeout(timer);
125
+ server.close();
126
+ server.closeAllConnections();
127
+ },
128
+ };
129
+ }
130
+ function saveBrowserConnection(value) {
131
+ const saved = (0, user_config_js_1.loadUserConfig)();
132
+ if (saved.workspace) {
133
+ const old = (0, node_1.loadProducerWorkspace)(saved.workspace);
134
+ if (old.apiUrl === value.workspace.apiUrl &&
135
+ old.encryption.teamId === value.workspace.encryption.teamId &&
136
+ (old.encryption.deploymentId !== value.workspace.encryption.deploymentId ||
137
+ old.encryption.trustedRoot !== value.workspace.encryption.trustedRoot))
138
+ throw new Error('The office encryption identity changed. Reconnect only after checking with your team owner.');
139
+ }
140
+ const directory = (0, node_path_1.join)((0, node_path_1.dirname)((0, user_config_js_1.userConfigPath)()), 'workspaces');
141
+ (0, node_fs_1.mkdirSync)(directory, { recursive: true, mode: 0o700 });
142
+ const name = (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(value.workspace)).digest('hex');
143
+ const workspace = (0, node_path_1.join)(directory, name + '.json');
144
+ const temporary = workspace + '.' + (0, node_crypto_1.randomBytes)(8).toString('hex') + '.tmp';
145
+ (0, node_fs_1.writeFileSync)(temporary, JSON.stringify(value.workspace, null, 2) + '\n', {
146
+ mode: 0o600,
147
+ flag: 'wx',
148
+ });
149
+ (0, node_fs_1.renameSync)(temporary, workspace);
150
+ (0, user_config_js_1.saveUserConfig)({
151
+ workspace,
152
+ apiUrl: value.workspace.apiUrl,
153
+ apiKey: value.apiKey,
154
+ map: value.workspace.encryption.mapId,
155
+ });
156
+ }
157
+ function openConnectionBrowser(url) {
158
+ const command = process.platform === 'darwin'
159
+ ? 'open'
160
+ : process.platform === 'win32'
161
+ ? 'rundll32.exe'
162
+ : 'xdg-open';
163
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
164
+ const child = (0, node_child_process_1.spawn)(command, args, { stdio: 'ignore', detached: true });
165
+ child.on('error', () => { }); // The printed URL also supports systems without a browser launcher.
166
+ child.unref();
167
+ }
168
+ async function connectCloudflareOffice(apiUrl) {
169
+ const origin = new URL(apiUrl).origin;
170
+ const response = await fetch(new URL('/.well-known/nocville.json', origin), {
171
+ redirect: 'error',
172
+ signal: AbortSignal.timeout(8000),
173
+ });
174
+ if (response.status === 404)
175
+ return false;
176
+ if (!response.ok)
177
+ throw new Error('The office is unavailable. Try setup again shortly.');
178
+ const info = (await response.json());
179
+ if (!info.encryptedIngress)
180
+ return false;
181
+ if (typeof info.deploymentId !== 'string' || !/^[A-Za-z0-9:_-]{1,128}$/.test(info.deploymentId))
182
+ throw new Error('Invalid office identity.');
183
+ const connection = await browserConnection(origin, info.deploymentId, saveBrowserConnection);
184
+ console.log('Connect this computer in your browser. Sign in, choose your office, and click Connect.');
185
+ console.log(connection.url);
186
+ openConnectionBrowser(connection.url);
187
+ try {
188
+ await connection.done;
189
+ console.log('✓ Connected. Private NPC updates will be encrypted on this computer.');
190
+ }
191
+ finally {
192
+ connection.close();
193
+ }
194
+ return true;
195
+ }
196
+ //# sourceMappingURL=browser-connect.js.map
@@ -71,24 +71,57 @@ function ensureStableCliPath(cliPath, _force = true) {
71
71
  const pngRoot = findPackageRoot((0, node_path_1.dirname)(req.resolve('pngjs')), 'pngjs');
72
72
  if (!pngRoot)
73
73
  throw new Error('Cannot install hook runtime: pngjs is missing');
74
+ // Resolve the complete production dependency closure before copying. Encryption
75
+ // introduced nested SDK dependencies that the old two-package snapshot omitted.
76
+ const dependencies = new Map();
77
+ const collect = (root) => {
78
+ const manifest = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root, 'package.json'), 'utf8'));
79
+ const previous = dependencies.get(manifest.name);
80
+ if (previous) {
81
+ if (readVersion(previous) !== manifest.version)
82
+ throw new Error(`Cannot install hook runtime: conflicting versions of ${manifest.name}`);
83
+ return;
84
+ }
85
+ dependencies.set(manifest.name, root);
86
+ const resolveDependency = (0, node_module_1.createRequire)((0, node_path_1.join)(root, 'package.json'));
87
+ for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
88
+ const dependency = findPackageRoot((0, node_path_1.dirname)(resolveDependency.resolve(name)), name);
89
+ if (!dependency)
90
+ throw new Error(`Cannot install hook runtime: ${name} is missing`);
91
+ collect(dependency);
92
+ }
93
+ };
94
+ collect(sdkRoot);
95
+ collect(pngRoot);
96
+ const files = (root, prefix = '') => (0, node_fs_1.readdirSync)((0, node_path_1.join)(root, prefix), { withFileTypes: true })
97
+ .sort((a, b) => a.name.localeCompare(b.name))
98
+ .flatMap((entry) => {
99
+ if (entry.name === 'node_modules' ||
100
+ entry.name.startsWith('.') ||
101
+ entry.name.endsWith('.map'))
102
+ return [];
103
+ const relative = (0, node_path_1.join)(prefix, entry.name);
104
+ return (0, node_fs_1.statSync)((0, node_path_1.join)(root, relative)).isDirectory() ? files(root, relative) : [relative];
105
+ });
106
+ const dependencyFiles = [...dependencies].map(([name, root]) => ({
107
+ name,
108
+ root,
109
+ files: files(root),
110
+ }));
74
111
  const version = readVersion(pkgRoot);
75
112
  const hash = (0, node_crypto_1.createHash)('sha256');
76
- const hashTree = (root) => {
77
- for (const entry of (0, node_fs_1.readdirSync)(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
78
- hash.update(entry.name);
79
- const path = (0, node_path_1.join)(root, entry.name);
80
- if (entry.isDirectory())
81
- hashTree(path);
82
- else if (entry.isFile())
83
- hash.update((0, node_fs_1.readFileSync)(path));
113
+ hash.update((0, node_fs_1.readFileSync)((0, node_path_1.join)(pkgRoot, 'package.json')));
114
+ for (const file of files((0, node_path_1.join)(pkgRoot, 'dist'))) {
115
+ hash.update(file);
116
+ hash.update((0, node_fs_1.readFileSync)((0, node_path_1.join)(pkgRoot, 'dist', file)));
117
+ }
118
+ for (const dependency of dependencyFiles) {
119
+ hash.update(dependency.name);
120
+ for (const file of dependency.files) {
121
+ hash.update(file);
122
+ hash.update((0, node_fs_1.readFileSync)((0, node_path_1.join)(dependency.root, file)));
84
123
  }
85
- };
86
- for (const root of [pkgRoot, sdkRoot]) {
87
- hash.update((0, node_fs_1.readFileSync)((0, node_path_1.join)(root, 'package.json')));
88
- hashTree((0, node_path_1.join)(root, 'dist'));
89
124
  }
90
- hash.update((0, node_fs_1.readFileSync)((0, node_path_1.join)(pngRoot, 'package.json')));
91
- hashTree((0, node_path_1.join)(pngRoot, 'lib'));
92
125
  // Immutable snapshots also support multiple development builds of one version.
93
126
  // Never remove files a running watchdog or the current pointer may still use.
94
127
  const dest = (0, node_path_1.join)((0, node_path_1.dirname)((0, user_config_js_1.userConfigPath)()), 'runtime', `${version}-${hash.digest('hex').slice(0, 16)}`);
@@ -99,14 +132,13 @@ function ensureStableCliPath(cliPath, _force = true) {
99
132
  (0, node_fs_1.mkdirSync)(stage, { recursive: true, mode: 0o700 });
100
133
  (0, node_fs_1.cpSync)((0, node_path_1.join)(pkgRoot, 'package.json'), (0, node_path_1.join)(stage, 'package.json'));
101
134
  (0, node_fs_1.cpSync)((0, node_path_1.join)(pkgRoot, 'dist'), (0, node_path_1.join)(stage, 'dist'), { recursive: true });
102
- const sdkDest = (0, node_path_1.join)(stage, 'node_modules', '@nocville', 'node');
103
- (0, node_fs_1.mkdirSync)(sdkDest, { recursive: true });
104
- (0, node_fs_1.cpSync)((0, node_path_1.join)(sdkRoot, 'package.json'), (0, node_path_1.join)(sdkDest, 'package.json'));
105
- (0, node_fs_1.cpSync)((0, node_path_1.join)(sdkRoot, 'dist'), (0, node_path_1.join)(sdkDest, 'dist'), { recursive: true });
106
- const pngDest = (0, node_path_1.join)(stage, 'node_modules', 'pngjs');
107
- (0, node_fs_1.mkdirSync)(pngDest, { recursive: true });
108
- (0, node_fs_1.cpSync)((0, node_path_1.join)(pngRoot, 'package.json'), (0, node_path_1.join)(pngDest, 'package.json'));
109
- (0, node_fs_1.cpSync)((0, node_path_1.join)(pngRoot, 'lib'), (0, node_path_1.join)(pngDest, 'lib'), { recursive: true });
135
+ for (const dependency of dependencyFiles) {
136
+ const target = (0, node_path_1.join)(stage, 'node_modules', dependency.name);
137
+ for (const file of dependency.files) {
138
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)((0, node_path_1.join)(target, file)), { recursive: true });
139
+ (0, node_fs_1.cpSync)((0, node_path_1.join)(dependency.root, file), (0, node_path_1.join)(target, file), { dereference: true });
140
+ }
141
+ }
110
142
  try {
111
143
  (0, node_fs_1.renameSync)(stage, dest);
112
144
  }
package/dist/cli.js CHANGED
@@ -12,9 +12,9 @@ const USAGE = `
12
12
  nocville-npc <command> [options]
13
13
 
14
14
  Quick start (AI coworkers):
15
- nocville-npc setup Paste your agent key (hidden prompt),
15
+ nocville-npc setup Connect your office in the browser,
16
16
  then hook up installed coding agents
17
- (whichever are installed)
17
+ nocville-npc setup --reconnect Choose an office and renew this connection
18
18
  nocville-npc setup --api-key=<agent key> Same, non-interactive
19
19
  nocville-npc uninstall Remove all Nocville hooks
20
20
 
@@ -151,22 +151,27 @@ async function main() {
151
151
  console.log(USAGE);
152
152
  process.exit(1);
153
153
  }
154
- const config = resolveConfig(parsed.flags);
154
+ let config = resolveConfig(parsed.flags);
155
155
  const logger = new Logger('cli', config.debug);
156
156
  // Setup-style commands resolve the key: --api-key > env > saved > hidden prompt.
157
157
  const isAgentSetup = ['claude-code', 'codex', 'gemini', 'cursor'].includes(parsed.command) &&
158
158
  parsed.subcommand === 'setup' &&
159
159
  !parsed.flags['uninstall'];
160
160
  if (isAgentSetup || parsed.command === 'setup') {
161
- if (parsed.command === 'setup') {
162
- const { codexHome } = await import('./npcs/codex/defaults.js');
163
- if (!existsSync(join(homedir(), '.claude')) &&
164
- !existsSync(codexHome()) &&
165
- !existsSync(join(homedir(), '.gemini')) &&
166
- !existsSync(join(homedir(), '.cursor'))) {
167
- console.error('Nothing to set up: install Claude Code, Codex, Gemini CLI or Cursor first.');
168
- process.exitCode = 1;
169
- return;
161
+ if (!config.workspace || !config.apiKey.includes('.team_') || parsed.flags['reconnect']) {
162
+ const { connectCloudflareOffice } = await import('./core/browser-connect.js');
163
+ if (await connectCloudflareOffice(config.apiUrl)) {
164
+ const { loadUserConfig } = await import('./core/user-config.js');
165
+ const saved = loadUserConfig();
166
+ for (const [flag, value] of Object.entries({
167
+ 'api-key': saved.apiKey,
168
+ 'api-url': saved.apiUrl,
169
+ map: saved.map,
170
+ workspace: saved.workspace,
171
+ }))
172
+ if (value)
173
+ parsed.flags[flag] = value;
174
+ config = resolveConfig(parsed.flags);
170
175
  }
171
176
  }
172
177
  const key = await resolveSetupKey(parsed.flags);
@@ -174,6 +179,16 @@ async function main() {
174
179
  return;
175
180
  if (key !== undefined)
176
181
  parsed.flags['api-key'] = key;
182
+ config = resolveConfig(parsed.flags);
183
+ if (config.workspace) {
184
+ const { saveUserConfig } = await import('./core/user-config.js');
185
+ saveUserConfig({
186
+ workspace: config.workspace,
187
+ apiUrl: config.apiUrl,
188
+ apiKey: config.apiKey,
189
+ map: config.map,
190
+ });
191
+ }
177
192
  }
178
193
  // AI-agent integrations have their own config handling (hooks read the saved config).
179
194
  if (parsed.command === 'claude-code') {
@@ -256,6 +271,10 @@ async function main() {
256
271
  return;
257
272
  }
258
273
  // All other NPC types require api-url and token
274
+ if (!config.workspace &&
275
+ (config.apiKey.includes('.team_') ||
276
+ config.apiUrl.replace(/\/+$/, '') === 'https://nocville.com'))
277
+ throw new Error('Connect this computer to your office first: run npx @nocville/npc@latest setup. Existing legacy keys cannot publish encrypted NPC updates.');
259
278
  if (!config.apiKey) {
260
279
  console.error('Error: no API key — run `nocville-npc init --api-key=<key>`, or pass --api-key / NOCVILLE_API_KEY');
261
280
  process.exit(1);
@@ -604,8 +623,7 @@ async function runSetupAll(flags) {
604
623
  console.log('· Cursor not found (~/.cursor missing) — skipped');
605
624
  }
606
625
  if (!any) {
607
- console.error('Nothing to set up: install Claude Code, Codex, Gemini CLI or Cursor first.');
608
- process.exitCode = 1;
626
+ console.log('No supported coding agents found. You can still run NPC commands on this computer.');
609
627
  }
610
628
  }
611
629
  main().catch((err) => {
@@ -0,0 +1,16 @@
1
+ import { type ProducerWorkspace } from '@nocville/node';
2
+ export interface BrowserConnection {
3
+ apiKey: string;
4
+ workspace: ProducerWorkspace;
5
+ }
6
+ export declare function validateConnection(input: unknown, origin: string, deploymentId: string): BrowserConnection;
7
+ /** A one-use loopback receiver. Credentials never appear in a URL or terminal output. */
8
+ export declare function browserConnection(origin: string, deploymentId: string, save: (value: BrowserConnection) => void | Promise<void>, timeoutMs?: number): Promise<{
9
+ url: string;
10
+ done: Promise<void>;
11
+ close: () => void;
12
+ }>;
13
+ export declare function saveBrowserConnection(value: BrowserConnection): void;
14
+ export declare function openConnectionBrowser(url: string): void;
15
+ export declare function connectCloudflareOffice(apiUrl: string): Promise<boolean>;
16
+ //# sourceMappingURL=browser-connect.d.ts.map
@@ -0,0 +1,189 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, createHash } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { loadProducerWorkspace } from '@nocville/node';
7
+ import { loadUserConfig, saveUserConfig, userConfigPath } from './user-config.js';
8
+ export function validateConnection(input, origin, deploymentId) {
9
+ const value = input;
10
+ const e = value?.workspace?.encryption;
11
+ if (typeof value?.apiKey !== 'string' ||
12
+ !/^nvk_[a-f0-9]{64}\.team_[A-Za-z0-9_-]{1,64}$/.test(value.apiKey) ||
13
+ value.workspace?.apiUrl !== origin ||
14
+ e?.deploymentId !== deploymentId ||
15
+ !/^team_[A-Za-z0-9_-]{1,64}$/.test(e?.teamId ?? '') ||
16
+ !/^map_[A-Za-z0-9_-]{1,128}$/.test(e?.mapId ?? '') ||
17
+ !/^[A-Za-z0-9_-]{43}$/.test(e?.trustedRoot ?? '') ||
18
+ !value.apiKey.endsWith('.' + e.teamId) ||
19
+ e.profileDirectory !== undefined)
20
+ throw new Error('Invalid office connection. Start setup again.');
21
+ return {
22
+ apiKey: value.apiKey,
23
+ workspace: {
24
+ apiUrl: origin,
25
+ encryption: {
26
+ deploymentId,
27
+ teamId: e.teamId,
28
+ mapId: e.mapId,
29
+ trustedRoot: e.trustedRoot,
30
+ },
31
+ },
32
+ };
33
+ }
34
+ /** A one-use loopback receiver. Credentials never appear in a URL or terminal output. */
35
+ export async function browserConnection(origin, deploymentId, save, timeoutMs = 300000) {
36
+ const target = new URL(origin);
37
+ if (target.origin !== origin ||
38
+ target.username ||
39
+ target.password ||
40
+ (target.protocol !== 'https:' &&
41
+ !(target.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname))))
42
+ throw new Error('Use an HTTPS office address.');
43
+ const state = randomBytes(32).toString('hex');
44
+ let finish, fail;
45
+ const done = new Promise((resolve, reject) => {
46
+ finish = resolve;
47
+ fail = reject;
48
+ });
49
+ // A timeout may happen while the browser launcher is still opening.
50
+ void done.catch(() => { });
51
+ let consumed = false, port = 0;
52
+ const server = createServer(async (request, response) => {
53
+ response.setHeader('Cache-Control', 'no-store');
54
+ if (request.headers.host !== `127.0.0.1:${port}` ||
55
+ request.url !== '/complete' ||
56
+ request.headers.origin !== origin) {
57
+ response.writeHead(403).end();
58
+ return;
59
+ }
60
+ response.setHeader('Access-Control-Allow-Origin', origin);
61
+ response.setHeader('Vary', 'Origin');
62
+ if (request.method === 'OPTIONS') {
63
+ response.setHeader('Access-Control-Allow-Methods', 'POST');
64
+ response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
65
+ response.setHeader('Access-Control-Allow-Private-Network', 'true');
66
+ response.writeHead(204).end();
67
+ return;
68
+ }
69
+ if (request.method !== 'POST' ||
70
+ request.headers['content-type']?.split(';')[0] !== 'application/json') {
71
+ response.writeHead(415).end();
72
+ return;
73
+ }
74
+ try {
75
+ let size = 0;
76
+ const chunks = [];
77
+ for await (const chunk of request) {
78
+ size += chunk.length;
79
+ if (size > 16384) {
80
+ response.writeHead(413).end();
81
+ return;
82
+ }
83
+ chunks.push(Buffer.from(chunk));
84
+ }
85
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
86
+ if (body.state !== state || consumed) {
87
+ response.writeHead(403).end();
88
+ return;
89
+ }
90
+ const value = validateConnection(body, origin, deploymentId);
91
+ consumed = true;
92
+ await save(value);
93
+ response.once('finish', finish);
94
+ response.writeHead(200, { 'Content-Type': 'application/json' }).end('{"connected":true}');
95
+ }
96
+ catch {
97
+ response.writeHead(400).end('Could not save this office connection. Start setup again.');
98
+ if (consumed)
99
+ fail(new Error('Could not save the office connection. Start setup again.'));
100
+ }
101
+ });
102
+ server.requestTimeout = 15000;
103
+ server.headersTimeout = 10000;
104
+ await new Promise((resolve, reject) => {
105
+ server.once('error', reject);
106
+ server.listen(0, '127.0.0.1', resolve);
107
+ });
108
+ port = server.address().port;
109
+ const timer = setTimeout(() => fail(new Error('Office connection timed out. Run setup again.')), timeoutMs);
110
+ const url = new URL('/npcs/connect', origin);
111
+ url.searchParams.set('port', String(port));
112
+ url.searchParams.set('state', state);
113
+ return {
114
+ url: url.href,
115
+ done,
116
+ close: () => {
117
+ clearTimeout(timer);
118
+ server.close();
119
+ server.closeAllConnections();
120
+ },
121
+ };
122
+ }
123
+ export function saveBrowserConnection(value) {
124
+ const saved = loadUserConfig();
125
+ if (saved.workspace) {
126
+ const old = loadProducerWorkspace(saved.workspace);
127
+ if (old.apiUrl === value.workspace.apiUrl &&
128
+ old.encryption.teamId === value.workspace.encryption.teamId &&
129
+ (old.encryption.deploymentId !== value.workspace.encryption.deploymentId ||
130
+ old.encryption.trustedRoot !== value.workspace.encryption.trustedRoot))
131
+ throw new Error('The office encryption identity changed. Reconnect only after checking with your team owner.');
132
+ }
133
+ const directory = join(dirname(userConfigPath()), 'workspaces');
134
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
135
+ const name = createHash('sha256').update(JSON.stringify(value.workspace)).digest('hex');
136
+ const workspace = join(directory, name + '.json');
137
+ const temporary = workspace + '.' + randomBytes(8).toString('hex') + '.tmp';
138
+ writeFileSync(temporary, JSON.stringify(value.workspace, null, 2) + '\n', {
139
+ mode: 0o600,
140
+ flag: 'wx',
141
+ });
142
+ renameSync(temporary, workspace);
143
+ saveUserConfig({
144
+ workspace,
145
+ apiUrl: value.workspace.apiUrl,
146
+ apiKey: value.apiKey,
147
+ map: value.workspace.encryption.mapId,
148
+ });
149
+ }
150
+ export function openConnectionBrowser(url) {
151
+ const command = process.platform === 'darwin'
152
+ ? 'open'
153
+ : process.platform === 'win32'
154
+ ? 'rundll32.exe'
155
+ : 'xdg-open';
156
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
157
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
158
+ child.on('error', () => { }); // The printed URL also supports systems without a browser launcher.
159
+ child.unref();
160
+ }
161
+ export async function connectCloudflareOffice(apiUrl) {
162
+ const origin = new URL(apiUrl).origin;
163
+ const response = await fetch(new URL('/.well-known/nocville.json', origin), {
164
+ redirect: 'error',
165
+ signal: AbortSignal.timeout(8000),
166
+ });
167
+ if (response.status === 404)
168
+ return false;
169
+ if (!response.ok)
170
+ throw new Error('The office is unavailable. Try setup again shortly.');
171
+ const info = (await response.json());
172
+ if (!info.encryptedIngress)
173
+ return false;
174
+ if (typeof info.deploymentId !== 'string' || !/^[A-Za-z0-9:_-]{1,128}$/.test(info.deploymentId))
175
+ throw new Error('Invalid office identity.');
176
+ const connection = await browserConnection(origin, info.deploymentId, saveBrowserConnection);
177
+ console.log('Connect this computer in your browser. Sign in, choose your office, and click Connect.');
178
+ console.log(connection.url);
179
+ openConnectionBrowser(connection.url);
180
+ try {
181
+ await connection.done;
182
+ console.log('✓ Connected. Private NPC updates will be encrypted on this computer.');
183
+ }
184
+ finally {
185
+ connection.close();
186
+ }
187
+ return true;
188
+ }
189
+ //# sourceMappingURL=browser-connect.js.map
@@ -66,24 +66,57 @@ export function ensureStableCliPath(cliPath, _force = true) {
66
66
  const pngRoot = findPackageRoot(dirname(req.resolve('pngjs')), 'pngjs');
67
67
  if (!pngRoot)
68
68
  throw new Error('Cannot install hook runtime: pngjs is missing');
69
+ // Resolve the complete production dependency closure before copying. Encryption
70
+ // introduced nested SDK dependencies that the old two-package snapshot omitted.
71
+ const dependencies = new Map();
72
+ const collect = (root) => {
73
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
74
+ const previous = dependencies.get(manifest.name);
75
+ if (previous) {
76
+ if (readVersion(previous) !== manifest.version)
77
+ throw new Error(`Cannot install hook runtime: conflicting versions of ${manifest.name}`);
78
+ return;
79
+ }
80
+ dependencies.set(manifest.name, root);
81
+ const resolveDependency = createRequire(join(root, 'package.json'));
82
+ for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
83
+ const dependency = findPackageRoot(dirname(resolveDependency.resolve(name)), name);
84
+ if (!dependency)
85
+ throw new Error(`Cannot install hook runtime: ${name} is missing`);
86
+ collect(dependency);
87
+ }
88
+ };
89
+ collect(sdkRoot);
90
+ collect(pngRoot);
91
+ const files = (root, prefix = '') => readdirSync(join(root, prefix), { withFileTypes: true })
92
+ .sort((a, b) => a.name.localeCompare(b.name))
93
+ .flatMap((entry) => {
94
+ if (entry.name === 'node_modules' ||
95
+ entry.name.startsWith('.') ||
96
+ entry.name.endsWith('.map'))
97
+ return [];
98
+ const relative = join(prefix, entry.name);
99
+ return statSync(join(root, relative)).isDirectory() ? files(root, relative) : [relative];
100
+ });
101
+ const dependencyFiles = [...dependencies].map(([name, root]) => ({
102
+ name,
103
+ root,
104
+ files: files(root),
105
+ }));
69
106
  const version = readVersion(pkgRoot);
70
107
  const hash = createHash('sha256');
71
- const hashTree = (root) => {
72
- for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
73
- hash.update(entry.name);
74
- const path = join(root, entry.name);
75
- if (entry.isDirectory())
76
- hashTree(path);
77
- else if (entry.isFile())
78
- hash.update(readFileSync(path));
108
+ hash.update(readFileSync(join(pkgRoot, 'package.json')));
109
+ for (const file of files(join(pkgRoot, 'dist'))) {
110
+ hash.update(file);
111
+ hash.update(readFileSync(join(pkgRoot, 'dist', file)));
112
+ }
113
+ for (const dependency of dependencyFiles) {
114
+ hash.update(dependency.name);
115
+ for (const file of dependency.files) {
116
+ hash.update(file);
117
+ hash.update(readFileSync(join(dependency.root, file)));
79
118
  }
80
- };
81
- for (const root of [pkgRoot, sdkRoot]) {
82
- hash.update(readFileSync(join(root, 'package.json')));
83
- hashTree(join(root, 'dist'));
84
119
  }
85
- hash.update(readFileSync(join(pngRoot, 'package.json')));
86
- hashTree(join(pngRoot, 'lib'));
87
120
  // Immutable snapshots also support multiple development builds of one version.
88
121
  // Never remove files a running watchdog or the current pointer may still use.
89
122
  const dest = join(dirname(userConfigPath()), 'runtime', `${version}-${hash.digest('hex').slice(0, 16)}`);
@@ -94,14 +127,13 @@ export function ensureStableCliPath(cliPath, _force = true) {
94
127
  mkdirSync(stage, { recursive: true, mode: 0o700 });
95
128
  cpSync(join(pkgRoot, 'package.json'), join(stage, 'package.json'));
96
129
  cpSync(join(pkgRoot, 'dist'), join(stage, 'dist'), { recursive: true });
97
- const sdkDest = join(stage, 'node_modules', '@nocville', 'node');
98
- mkdirSync(sdkDest, { recursive: true });
99
- cpSync(join(sdkRoot, 'package.json'), join(sdkDest, 'package.json'));
100
- cpSync(join(sdkRoot, 'dist'), join(sdkDest, 'dist'), { recursive: true });
101
- const pngDest = join(stage, 'node_modules', 'pngjs');
102
- mkdirSync(pngDest, { recursive: true });
103
- cpSync(join(pngRoot, 'package.json'), join(pngDest, 'package.json'));
104
- cpSync(join(pngRoot, 'lib'), join(pngDest, 'lib'), { recursive: true });
130
+ for (const dependency of dependencyFiles) {
131
+ const target = join(stage, 'node_modules', dependency.name);
132
+ for (const file of dependency.files) {
133
+ mkdirSync(dirname(join(target, file)), { recursive: true });
134
+ cpSync(join(dependency.root, file), join(target, file), { dereference: true });
135
+ }
136
+ }
105
137
  try {
106
138
  renameSync(stage, dest);
107
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocville/npc",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Put your Claude Code and Codex sessions (and your servers) in the Nocville office as live NPCs",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "pngjs": "^7.0.0",
27
- "@nocville/node": "^0.4.0"
27
+ "@nocville/node": "^0.4.1"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "puppeteer-core": "*"