notioncode 0.1.0 → 0.1.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
@@ -1,26 +1,28 @@
1
- # @nocodenotioncode
1
+ # notioncode
2
2
 
3
- Run the Notion Code local companion without opening the Tauri desktop shell.
3
+ Run the local companion runtime behind the `npx create notioncode` flow.
4
4
 
5
5
  ## Intended usage
6
6
 
7
7
  ```bash
8
- npx @nocodenotioncode start
8
+ npx create notioncode
9
9
  ```
10
10
 
11
11
  Options:
12
12
 
13
13
  ```bash
14
- npx @nocodenotioncode install
15
- npx @nocodenotioncode doctor
16
- npx @nocodenotioncode start --with-local-ui
14
+ npx notioncode install
15
+ npx notioncode doctor
16
+ npx notioncode start
17
17
  ```
18
18
 
19
19
  ## What it starts
20
20
 
21
21
  - localhost bridge on `127.0.0.1:3456`
22
- - optional local UI on `127.0.0.1:1420`
22
+ - local UI on `http://127.0.0.1:1420` for non-Safari default browsers
23
+ - local UI on `https://localhost:1420` when the default browser is Safari
23
24
  - browser handoff to `https://www.notioncode.live`
25
+ - trusted localhost certificate provisioning with `mkcert` only when Safari-compatible HTTPS is needed
24
26
 
25
27
  ## Bridge binaries
26
28
 
@@ -33,6 +35,11 @@ Environment variables:
33
35
  - `NOCODE_CLOUD_URL`
34
36
  - `NOCODE_NO_OPEN=1`
35
37
 
38
+ ## Safari support
39
+
40
+ When the system default browser is Safari, this package switches the local UI to `https://localhost:1420`.
41
+ That path requires a trusted localhost certificate, so the setup flow will install or download `mkcert` and ask the OS to trust a local CA when needed.
42
+
36
43
  ## Current limitation
37
44
 
38
- This package is implemented inside the source tree and still expects local runtime assets from this repository. It is publication-ready in interface and metadata, but the final standalone distribution still needs packaged runtime assets for the agent sidecar.
45
+ This package still expects local runtime assets from this repository. It is close to publication-ready in interface and metadata, but the final standalone distribution still needs packaged runtime assets for the agent sidecar.
@@ -3,7 +3,7 @@
3
3
  import process from 'node:process';
4
4
 
5
5
  import { ensureBridgeBinary, buildLocalBridgeAsset } from '../lib/install.js';
6
- import { startLocalCompanion } from '../lib/start.js';
6
+ import { runDoctor, startLocalCompanion } from '../lib/start.js';
7
7
 
8
8
  async function main() {
9
9
  const args = process.argv.slice(2);
@@ -14,13 +14,7 @@ async function main() {
14
14
  }
15
15
 
16
16
  if (command === 'doctor') {
17
- const checks = [
18
- ['node', process.version],
19
- ['platform', `${process.platform}/${process.arch}`],
20
- ];
21
- for (const [label, detail] of checks) {
22
- console.log(`[OK] ${label}: ${detail}`);
23
- }
17
+ await runDoctor();
24
18
  return;
25
19
  }
26
20
 
package/lib/certs.js ADDED
@@ -0,0 +1,332 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { chmodSync, createWriteStream, existsSync, mkdirSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
5
+ import https from 'node:https';
6
+
7
+ import { defaultCacheDir } from './platform.js';
8
+
9
+ function appDataDir() {
10
+ return path.join(defaultCacheDir(), 'local-https');
11
+ }
12
+
13
+ function localBinDir() {
14
+ return path.join(appDataDir(), 'bin');
15
+ }
16
+
17
+ function ensureDir(dirPath) {
18
+ mkdirSync(dirPath, { recursive: true });
19
+ }
20
+
21
+ function pathEntries() {
22
+ return (process.env.PATH || '').split(path.delimiter).filter(Boolean);
23
+ }
24
+
25
+ function resolveCommand(command) {
26
+ const names = process.platform === 'win32' ? [command, `${command}.exe`, `${command}.cmd`] : [command];
27
+ const extraDirs = process.platform === 'darwin'
28
+ ? ['/opt/homebrew/bin', '/usr/local/bin', localBinDir()]
29
+ : process.platform === 'win32'
30
+ ? [
31
+ 'C:\\ProgramData\\chocolatey\\bin',
32
+ path.join(os.homedir(), 'scoop', 'shims'),
33
+ localBinDir(),
34
+ ]
35
+ : [localBinDir()];
36
+
37
+ for (const dir of [...pathEntries(), ...extraDirs]) {
38
+ for (const name of names) {
39
+ const fullPath = path.join(dir, name);
40
+ if (existsSync(fullPath)) {
41
+ return fullPath;
42
+ }
43
+ }
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ function hasCommand(command) {
50
+ return Boolean(resolveCommand(command));
51
+ }
52
+
53
+ function runResolvedCommand(command, args, options = {}) {
54
+ const resolved = resolveCommand(command);
55
+ if (!resolved) {
56
+ throw new Error(`Command not found: ${command}`);
57
+ }
58
+
59
+ return execFileSync(resolved, args, {
60
+ stdio: 'inherit',
61
+ ...options,
62
+ });
63
+ }
64
+
65
+ function certificatePaths() {
66
+ const dir = path.join(appDataDir(), 'certs');
67
+ return {
68
+ dir,
69
+ certFile: path.join(dir, 'localhost.pem'),
70
+ keyFile: path.join(dir, 'localhost-key.pem'),
71
+ };
72
+ }
73
+
74
+ function hasCertificateFiles(paths) {
75
+ return existsSync(paths.certFile) && existsSync(paths.keyFile);
76
+ }
77
+
78
+ function mkcertInstallInstructions() {
79
+ if (process.platform === 'darwin') {
80
+ return ['brew install mkcert', 'mkcert -install'];
81
+ }
82
+
83
+ if (process.platform === 'win32') {
84
+ return ['choco install mkcert', 'mkcert -install'];
85
+ }
86
+
87
+ return ['install mkcert with your package manager', 'mkcert -install'];
88
+ }
89
+
90
+ function mkcertAssetMatcher() {
91
+ if (process.platform === 'darwin' && process.arch === 'arm64') {
92
+ return /darwin-arm64$/;
93
+ }
94
+ if (process.platform === 'darwin' && process.arch === 'x64') {
95
+ return /darwin-amd64$|darwin-x86_64$|darwin-x64$/;
96
+ }
97
+ if (process.platform === 'linux' && process.arch === 'x64') {
98
+ return /linux-amd64$|linux-x86_64$|linux-x64$/;
99
+ }
100
+ if (process.platform === 'win32' && process.arch === 'x64') {
101
+ return /windows-amd64\.exe$|windows-x86_64\.exe$|windows-x64\.exe$/;
102
+ }
103
+
104
+ throw new Error(`Unsupported platform for automatic mkcert download: ${process.platform}/${process.arch}`);
105
+ }
106
+
107
+ function requestJson(url) {
108
+ return new Promise((resolve, reject) => {
109
+ https
110
+ .get(
111
+ url,
112
+ {
113
+ headers: {
114
+ 'User-Agent': 'notioncode-local',
115
+ Accept: 'application/vnd.github+json',
116
+ },
117
+ },
118
+ (response) => {
119
+ if ((response.statusCode || 0) >= 300 && (response.statusCode || 0) < 400 && response.headers.location) {
120
+ response.resume();
121
+ requestJson(response.headers.location).then(resolve, reject);
122
+ return;
123
+ }
124
+
125
+ if ((response.statusCode || 0) >= 400) {
126
+ response.resume();
127
+ reject(new Error(`GitHub API request failed with HTTP ${response.statusCode}`));
128
+ return;
129
+ }
130
+
131
+ let body = '';
132
+ response.setEncoding('utf8');
133
+ response.on('data', (chunk) => {
134
+ body += chunk;
135
+ });
136
+ response.on('end', () => {
137
+ try {
138
+ resolve(JSON.parse(body));
139
+ } catch (error) {
140
+ reject(error);
141
+ }
142
+ });
143
+ }
144
+ )
145
+ .on('error', reject);
146
+ });
147
+ }
148
+
149
+ function downloadFile(url, destination) {
150
+ return new Promise((resolve, reject) => {
151
+ https
152
+ .get(
153
+ url,
154
+ {
155
+ headers: {
156
+ 'User-Agent': 'notioncode-local',
157
+ Accept: 'application/octet-stream',
158
+ },
159
+ },
160
+ (response) => {
161
+ if ((response.statusCode || 0) >= 300 && (response.statusCode || 0) < 400 && response.headers.location) {
162
+ response.resume();
163
+ downloadFile(response.headers.location, destination).then(resolve, reject);
164
+ return;
165
+ }
166
+
167
+ if ((response.statusCode || 0) >= 400) {
168
+ response.resume();
169
+ reject(new Error(`mkcert download failed with HTTP ${response.statusCode}`));
170
+ return;
171
+ }
172
+
173
+ const output = createWriteStream(destination, { mode: 0o755 });
174
+ response.pipe(output);
175
+ output.on('finish', () => {
176
+ output.close(() => resolve(destination));
177
+ });
178
+ output.on('error', reject);
179
+ }
180
+ )
181
+ .on('error', reject);
182
+ });
183
+ }
184
+
185
+ async function installStandaloneMkcert() {
186
+ ensureDir(localBinDir());
187
+
188
+ const latestRelease = await requestJson('https://api.github.com/repos/FiloSottile/mkcert/releases/latest');
189
+ const matcher = mkcertAssetMatcher();
190
+ const asset = Array.isArray(latestRelease.assets)
191
+ ? latestRelease.assets.find((entry) => typeof entry?.name === 'string' && matcher.test(entry.name))
192
+ : null;
193
+
194
+ if (!asset?.browser_download_url) {
195
+ throw new Error(`Could not find a mkcert download for ${process.platform}/${process.arch}.`);
196
+ }
197
+
198
+ const targetName = process.platform === 'win32' ? 'mkcert.exe' : 'mkcert';
199
+ const targetPath = path.join(localBinDir(), targetName);
200
+ console.log(`[notioncode] Downloading mkcert to ${targetPath} ...`);
201
+ await downloadFile(asset.browser_download_url, targetPath);
202
+ if (process.platform !== 'win32') {
203
+ chmodSync(targetPath, 0o755);
204
+ }
205
+ return targetPath;
206
+ }
207
+
208
+ function installMkcertIfPossible() {
209
+ if (hasCommand('mkcert')) {
210
+ return;
211
+ }
212
+
213
+ if (process.platform === 'darwin' && hasCommand('brew')) {
214
+ console.log('[notioncode] Installing mkcert with Homebrew...');
215
+ runResolvedCommand('brew', ['install', 'mkcert']);
216
+ if (!hasCommand('mkcert')) {
217
+ throw new Error('Homebrew completed, but mkcert is still not available on PATH.');
218
+ }
219
+ return;
220
+ }
221
+
222
+ if (process.platform === 'win32' && hasCommand('choco')) {
223
+ console.log('[notioncode] Installing mkcert with Chocolatey...');
224
+ runResolvedCommand('choco', ['install', 'mkcert', '-y']);
225
+ if (!hasCommand('mkcert')) {
226
+ throw new Error('Chocolatey completed, but mkcert is still not available on PATH.');
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (process.platform === 'win32' && hasCommand('scoop')) {
232
+ console.log('[notioncode] Installing mkcert with Scoop...');
233
+ runResolvedCommand('scoop', ['install', 'mkcert']);
234
+ if (!hasCommand('mkcert')) {
235
+ throw new Error('Scoop completed, but mkcert is still not available on PATH.');
236
+ }
237
+ return;
238
+ }
239
+
240
+ console.log('[notioncode] No package-manager mkcert installation path was detected. Falling back to a direct mkcert download...');
241
+ throw new Error('__DOWNLOAD_MKCERT__');
242
+ }
243
+
244
+ async function ensureMkcertAvailable() {
245
+ try {
246
+ installMkcertIfPossible();
247
+ } catch (error) {
248
+ if (error instanceof Error && error.message === '__DOWNLOAD_MKCERT__') {
249
+ return installStandaloneMkcert();
250
+ }
251
+
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ async function waitForMkcertOnPath() {
257
+ const resolved = resolveCommand('mkcert');
258
+ if (!resolved) {
259
+ throw new Error('mkcert installation completed, but the binary is still unavailable.');
260
+ }
261
+ return resolved;
262
+ }
263
+
264
+ async function ensureMkcertReady() {
265
+ await ensureMkcertAvailable();
266
+ return waitForMkcertOnPath();
267
+ }
268
+
269
+ async function installMkcertAndTrust() {
270
+ await ensureMkcertReady();
271
+ }
272
+
273
+ async function installTrustedLocalCa() {
274
+ await installMkcertAndTrust();
275
+
276
+ console.log(
277
+ '[notioncode] NotionCode needs to install a local certificate authority so Safari can trust https://localhost:1420.'
278
+ );
279
+ console.log(
280
+ '[notioncode] Your OS may ask for approval or your system password because this updates the local trust store.'
281
+ );
282
+ runResolvedCommand('mkcert', ['-install']);
283
+ }
284
+
285
+ function generateTrustedLocalhostCert(paths) {
286
+ console.log('[notioncode] Generating trusted localhost certificate...');
287
+ runResolvedCommand('mkcert', [
288
+ '-cert-file',
289
+ paths.certFile,
290
+ '-key-file',
291
+ paths.keyFile,
292
+ 'localhost',
293
+ '127.0.0.1',
294
+ '::1',
295
+ ]);
296
+ }
297
+
298
+ export async function ensureTrustedLocalhostCert() {
299
+ const paths = certificatePaths();
300
+ ensureDir(paths.dir);
301
+
302
+ await installTrustedLocalCa();
303
+
304
+ if (!hasCertificateFiles(paths)) {
305
+ generateTrustedLocalhostCert(paths);
306
+ }
307
+
308
+ return {
309
+ certFile: paths.certFile,
310
+ keyFile: paths.keyFile,
311
+ certDir: paths.dir,
312
+ appDataDir: appDataDir(),
313
+ localBinDir: localBinDir(),
314
+ };
315
+ }
316
+
317
+ export function diagnoseTrustedLocalhostCert() {
318
+ const paths = certificatePaths();
319
+
320
+ return {
321
+ appDataDir: appDataDir(),
322
+ certDir: paths.dir,
323
+ certFile: paths.certFile,
324
+ keyFile: paths.keyFile,
325
+ mkcertInstalled: hasCommand('mkcert'),
326
+ mkcertPath: resolveCommand('mkcert'),
327
+ brewInstalled: hasCommand('brew'),
328
+ brewPath: resolveCommand('brew'),
329
+ certExists: existsSync(paths.certFile),
330
+ keyExists: existsSync(paths.keyFile),
331
+ };
332
+ }
package/lib/install.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync } from 'node:fs';
1
+ import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { pipeline } from 'node:stream/promises';
@@ -7,11 +7,24 @@ import { fileURLToPath } from 'node:url';
7
7
 
8
8
  import { commandForPlatform, defaultCacheDir, resolvePlatformTarget } from './platform.js';
9
9
 
10
- const DEFAULT_VERSION = '0.1.0';
11
10
  const DEFAULT_ASSET_BASE =
12
11
  process.env.NOCODE_COMPANION_ASSET_BASE_URL ||
13
12
  'https://github.com/tadkt/nocode/releases/download';
14
13
 
14
+ function packageVersion() {
15
+ try {
16
+ const packageJsonPath = path.join(packageRootDir(), 'package.json');
17
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
18
+ return typeof packageJson.version === 'string' && packageJson.version.trim()
19
+ ? packageJson.version.trim()
20
+ : '0.1.0';
21
+ } catch {
22
+ return '0.1.0';
23
+ }
24
+ }
25
+
26
+ const DEFAULT_VERSION = packageVersion();
27
+
15
28
  function packageRootDir() {
16
29
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
30
  }
package/lib/start.js CHANGED
@@ -1,14 +1,20 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
1
3
  import path from 'node:path';
2
4
  import { spawn, spawnSync } from 'node:child_process';
3
5
  import { existsSync } from 'node:fs';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import { setTimeout as sleep } from 'node:timers/promises';
6
8
 
9
+ import defaultBrowserId from 'default-browser-id';
10
+
7
11
  import { commandForPlatform } from './platform.js';
8
12
  import { ensureBridgeBinary } from './install.js';
13
+ import { diagnoseTrustedLocalhostCert, ensureTrustedLocalhostCert } from './certs.js';
9
14
 
10
15
  const BRIDGE_HEALTH_URL = 'http://127.0.0.1:3456/healthz';
11
16
  const LOCAL_UI_URL = 'http://127.0.0.1:1420/';
17
+ const LOCAL_UI_HTTPS_URL = 'https://localhost:1420/';
12
18
  const CLOUD_URL = process.env.NOCODE_CLOUD_URL || 'https://www.notioncode.live';
13
19
 
14
20
  function packageRootDir() {
@@ -44,10 +50,34 @@ function spawnLogged(cmd, args, options = {}) {
44
50
  });
45
51
  }
46
52
 
53
+ function requestReady(url, options = {}) {
54
+ const target = new URL(url);
55
+ const client = target.protocol === 'https:' ? https : http;
56
+
57
+ return new Promise((resolve, reject) => {
58
+ const request = client.request(
59
+ target,
60
+ {
61
+ method: 'GET',
62
+ ...(target.protocol === 'https:' ? { rejectUnauthorized: options.rejectUnauthorized !== false } : {}),
63
+ },
64
+ (response) => {
65
+ response.resume();
66
+ resolve(response.statusCode >= 200 && response.statusCode < 400);
67
+ }
68
+ );
69
+
70
+ request.on('error', reject);
71
+ request.setTimeout(2_000, () => {
72
+ request.destroy(new Error(`Timed out waiting for ${url}`));
73
+ });
74
+ request.end();
75
+ });
76
+ }
77
+
47
78
  async function isReady(url) {
48
79
  try {
49
- const response = await fetch(url, { method: 'GET' });
50
- return response.ok;
80
+ return await requestReady(url);
51
81
  } catch {
52
82
  return false;
53
83
  }
@@ -58,9 +88,9 @@ async function waitFor(url, timeoutMs, label) {
58
88
  let lastError = null;
59
89
  while (Date.now() < deadline) {
60
90
  try {
61
- const response = await fetch(url, { method: 'GET' });
62
- if (response.ok) return;
63
- lastError = new Error(`${label} returned ${response.status}`);
91
+ const ready = await requestReady(url);
92
+ if (ready) return;
93
+ lastError = new Error(`${label} returned a non-ready response`);
64
94
  } catch (error) {
65
95
  lastError = error;
66
96
  }
@@ -69,6 +99,26 @@ async function waitFor(url, timeoutMs, label) {
69
99
  throw lastError || new Error(`${label} did not become ready.`);
70
100
  }
71
101
 
102
+ async function verifyTrustedBridge(url) {
103
+ const deadline = Date.now() + 10_000;
104
+ let lastError = null;
105
+
106
+ while (Date.now() < deadline) {
107
+ try {
108
+ const ready = await requestReady(url, { rejectUnauthorized: true });
109
+ if (ready) {
110
+ return;
111
+ }
112
+ lastError = new Error(`Trusted bridge check returned a non-ready response for ${url}`);
113
+ } catch (error) {
114
+ lastError = error;
115
+ }
116
+ await sleep(300);
117
+ }
118
+
119
+ throw lastError || new Error(`Timed out verifying trusted bridge at ${url}`);
120
+ }
121
+
72
122
  function openBrowser(url) {
73
123
  if (process.env.NOCODE_NO_OPEN === '1') return;
74
124
  if (process.platform === 'darwin') {
@@ -82,6 +132,73 @@ function openBrowser(url) {
82
132
  spawn(commandForPlatform('xdg-open'), [url], { stdio: 'ignore', detached: true }).unref();
83
133
  }
84
134
 
135
+ function trimOutput(value) {
136
+ return typeof value === 'string' ? value.trim() : '';
137
+ }
138
+
139
+ function detectDefaultBrowser() {
140
+ if (process.env.NOCODE_DEFAULT_BROWSER?.trim()) {
141
+ return process.env.NOCODE_DEFAULT_BROWSER.trim();
142
+ }
143
+
144
+ try {
145
+ if (process.platform === 'darwin') {
146
+ const bundleId = defaultBrowserId();
147
+ if (bundleId) {
148
+ return trimOutput(bundleId);
149
+ }
150
+ }
151
+
152
+ if (process.platform === 'win32') {
153
+ const result = spawnSync(
154
+ 'reg',
155
+ [
156
+ 'query',
157
+ 'HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice',
158
+ '/v',
159
+ 'ProgId',
160
+ ],
161
+ { encoding: 'utf8' }
162
+ );
163
+ if (result.status === 0) {
164
+ return trimOutput(result.stdout);
165
+ }
166
+ }
167
+
168
+ const result = spawnSync('xdg-settings', ['get', 'default-web-browser'], {
169
+ encoding: 'utf8',
170
+ });
171
+ if (result.status === 0) {
172
+ return trimOutput(result.stdout);
173
+ }
174
+ } catch {
175
+ // Ignore detection failures and fall back to HTTP.
176
+ }
177
+
178
+ return '';
179
+ }
180
+
181
+ function isSafariDefaultBrowser(defaultBrowser) {
182
+ const normalized = defaultBrowser.toLowerCase();
183
+ return (
184
+ normalized.includes('com.apple.safari') ||
185
+ normalized.includes('safari.app') ||
186
+ normalized.includes('safari technology preview')
187
+ );
188
+ }
189
+
190
+ function shouldUseHttpsLocalUi() {
191
+ const explicit = process.env.NOCODE_LOCAL_UI_HTTPS?.trim().toLowerCase();
192
+ if (explicit === '1' || explicit === 'true' || explicit === 'https') {
193
+ return true;
194
+ }
195
+ if (explicit === '0' || explicit === 'false' || explicit === 'http') {
196
+ return false;
197
+ }
198
+
199
+ return isSafariDefaultBrowser(detectDefaultBrowser());
200
+ }
201
+
85
202
  function hasLocalUiRepo() {
86
203
  const runtimeRoot = detectRuntimeRoot();
87
204
  return existsSync(path.join(runtimeRoot, 'package.json')) && existsSync(path.join(runtimeRoot, 'node_modules', 'vite', 'bin', 'vite.js'));
@@ -119,27 +236,44 @@ export async function startLocalCompanion(options = {}) {
119
236
  const requestedLocalUi = options.withLocalUi !== false;
120
237
  const shouldStartLocalUi = requestedLocalUi && hasLocalUiRepo();
121
238
  const useCargoBridge = hasCargoBridgeSource(runtimeRoot) && canUseCargo();
122
- const bridgeExecutable = useCargoBridge ? null : await ensureBridgeBinary(options.version);
239
+ const useHttpsLocalUi =
240
+ typeof options.localUiHttps === 'boolean' ? options.localUiHttps : shouldUseHttpsLocalUi();
241
+ const localUiUrl = useHttpsLocalUi ? LOCAL_UI_HTTPS_URL : LOCAL_UI_URL;
242
+ const localUiHost = useHttpsLocalUi ? 'localhost' : '127.0.0.1';
243
+ let localHttps = null;
244
+
245
+ if (shouldStartLocalUi && useHttpsLocalUi) {
246
+ localHttps = await ensureTrustedLocalhostCert();
247
+ }
123
248
 
124
249
  ensureSidecarInstallIfRepoPresent();
125
250
 
126
251
  let vite = null;
127
252
  if (shouldStartLocalUi) {
128
- if (await isReady(LOCAL_UI_URL)) {
129
- console.log('[notioncode] Reusing existing local UI on http://127.0.0.1:1420 .');
253
+ if (await isReady(localUiUrl)) {
254
+ console.log(`[notioncode] Reusing existing local UI on ${localUiUrl} .`);
130
255
  } else {
131
- console.log('[notioncode] Starting local UI on http://127.0.0.1:1420 ...');
132
- vite = spawnLogged(commandForPlatform('node'), ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1'], {
256
+ console.log(`[notioncode] Starting local UI on ${localUiUrl} ...`);
257
+ vite = spawnLogged(commandForPlatform('node'), ['node_modules/vite/bin/vite.js', '--host', localUiHost], {
133
258
  cwd: runtimeRoot,
134
259
  env: {
135
260
  VITE_SINGLE_USER_MODE: 'true',
261
+ NOCODE_LOCAL_UI_USE_HTTPS: useHttpsLocalUi ? '1' : '0',
262
+ NOCODE_LOCAL_UI_HOST: localUiHost,
263
+ ...(localHttps
264
+ ? {
265
+ NOCODE_LOCAL_UI_CERT_FILE: localHttps.certFile,
266
+ NOCODE_LOCAL_UI_KEY_FILE: localHttps.keyFile,
267
+ }
268
+ : {}),
136
269
  },
137
270
  });
138
271
  }
139
272
  }
140
273
 
141
274
  let bridge = null;
142
- if (await isReady(BRIDGE_HEALTH_URL)) {
275
+ const bridgeAlreadyReady = await isReady(BRIDGE_HEALTH_URL);
276
+ if (bridgeAlreadyReady) {
143
277
  console.log('[notioncode] Reusing existing bridge on http://127.0.0.1:3456 .');
144
278
  } else {
145
279
  console.log('[notioncode] Starting local bridge on http://127.0.0.1:3456 ...');
@@ -150,6 +284,7 @@ export async function startLocalCompanion(options = {}) {
150
284
  { cwd: runtimeRoot }
151
285
  );
152
286
  } else {
287
+ const bridgeExecutable = await ensureBridgeBinary(options.version);
153
288
  bridge = spawnLogged(bridgeExecutable, [], {
154
289
  cwd: runtimeRoot,
155
290
  env: {
@@ -180,15 +315,65 @@ export async function startLocalCompanion(options = {}) {
180
315
  const bridgeTimeoutMs = useCargoBridge ? 180_000 : 15_000;
181
316
  await waitFor(BRIDGE_HEALTH_URL, bridgeTimeoutMs, 'Bridge');
182
317
  if (shouldStartLocalUi) {
183
- await waitFor(LOCAL_UI_URL, 15_000, 'Local UI');
318
+ await waitFor(localUiUrl, 15_000, 'Local UI');
319
+ if (useHttpsLocalUi) {
320
+ try {
321
+ await verifyTrustedBridge(localUiUrl);
322
+ } catch (error) {
323
+ throw new Error(
324
+ `Safari-compatible local bridge could not be initialized.\n\nReason:\n ${error instanceof Error ? error.message : String(error)}\n\nFix:\n Install mkcert and trust the local certificate authority, then rerun:\n mkcert -install\n npx create notioncode`
325
+ );
326
+ }
327
+ }
184
328
  }
185
329
 
186
330
  console.log('[notioncode] Ready.');
187
331
  console.log(`[notioncode] Cloud UI: ${CLOUD_URL}`);
188
332
  if (shouldStartLocalUi) {
189
- console.log(`[notioncode] Local UI: ${LOCAL_UI_URL}`);
333
+ console.log(`[notioncode] Local UI: ${localUiUrl}`);
190
334
  }
191
- const entryUrl = shouldStartLocalUi ? LOCAL_UI_URL : CLOUD_URL;
335
+ const entryUrl = CLOUD_URL;
192
336
  console.log(`[notioncode] Opening: ${entryUrl}`);
193
337
  openBrowser(entryUrl);
194
338
  }
339
+
340
+ export async function runDoctor() {
341
+ const certs = diagnoseTrustedLocalhostCert();
342
+ const defaultBrowser = detectDefaultBrowser();
343
+ const useHttpsLocalUi = shouldUseHttpsLocalUi();
344
+ const checks = [
345
+ ['node', process.version],
346
+ ['platform', `${process.platform}/${process.arch}`],
347
+ ['default browser', defaultBrowser || 'unknown'],
348
+ ['local UI scheme', useHttpsLocalUi ? 'https' : 'http'],
349
+ ['mkcert', certs.mkcertInstalled ? 'installed' : 'missing'],
350
+ ['localhost cert', certs.certExists ? certs.certFile : 'missing'],
351
+ ['localhost key', certs.keyExists ? certs.keyFile : 'missing'],
352
+ ];
353
+
354
+ for (const [label, detail] of checks) {
355
+ console.log(`[OK] ${label}: ${detail}`);
356
+ }
357
+
358
+ const bridgeReady = await isReady(BRIDGE_HEALTH_URL);
359
+ console.log(`[${bridgeReady ? 'OK' : 'WARN'}] bridge healthz: ${bridgeReady ? BRIDGE_HEALTH_URL : 'not reachable'}`);
360
+
361
+ const selectedLocalUiUrl = useHttpsLocalUi ? LOCAL_UI_HTTPS_URL : LOCAL_UI_URL;
362
+ const localUiReachable = await isReady(selectedLocalUiUrl);
363
+ console.log(
364
+ `[${localUiReachable ? 'OK' : 'WARN'}] selected local UI: ${
365
+ localUiReachable ? selectedLocalUiUrl : 'not reachable'
366
+ }`
367
+ );
368
+
369
+ if (useHttpsLocalUi && localUiReachable) {
370
+ try {
371
+ await verifyTrustedBridge(LOCAL_UI_HTTPS_URL);
372
+ console.log(`[OK] trusted TLS: ${LOCAL_UI_HTTPS_URL}`);
373
+ } catch (error) {
374
+ console.log(
375
+ `[WARN] trusted TLS: ${error instanceof Error ? error.message : String(error)}`
376
+ );
377
+ }
378
+ }
379
+ }
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "notioncode",
3
- "version": "0.1.0",
4
- "description": "Run the Notion Code local companion bridge and open the cloud UI.",
3
+ "version": "0.1.1",
4
+ "description": "Local companion runtime used by the `npx create notioncode` setup flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "dependencies": {
8
+ "default-browser-id": "^5.0.1"
9
+ },
7
10
  "bin": {
8
11
  "notioncode": "bin/nocode-local.js"
9
12
  },