nolo-cli 0.1.12 → 0.1.13

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
@@ -62,6 +62,20 @@ nolo whoami
62
62
  nolo
63
63
  ```
64
64
 
65
+ By default, `nolo login` opens the Nolo website and waits for browser
66
+ authorization. In SSH or browserless environments, use:
67
+
68
+ ```bash
69
+ nolo login --no-browser
70
+ ```
71
+
72
+ Then open the printed URL on a logged-in browser. Automation can still save a
73
+ token directly:
74
+
75
+ ```bash
76
+ nolo login --server https://nolo.chat --token <token>
77
+ ```
78
+
65
79
  Local repo development can still use the script bridge without `AUTH_TOKEN`.
66
80
 
67
81
  Inside the TUI, `/update` is the shortcut for the same global `nolo update`
@@ -171,3 +185,34 @@ Future product-direction examples for the broader TUI command model:
171
185
 
172
186
  See [`docs/nolo-cli-tui.md`](../../docs/nolo-cli-tui.md) for the product and
173
187
  technical direction.
188
+
189
+ ## Building for Publish
190
+
191
+ The CLI is developed in a monorepo with workspace dependencies (`ai` and
192
+ `connector-experimental`). To generate a publish-safe package that can be
193
+ installed via npm outside the monorepo:
194
+
195
+ ```bash
196
+ bun run build:publish
197
+ ```
198
+
199
+ This creates a `dist/` directory with:
200
+ - All source files from the `files` array in package.json
201
+ - Inlined workspace dependencies (copied as nested directories)
202
+ - A modified package.json with workspace dependencies stripped
203
+
204
+ The `dist/` directory can be published to npm:
205
+
206
+ ```bash
207
+ cd dist
208
+ npm publish
209
+ ```
210
+
211
+ Key differences between repo-local and published versions:
212
+ - **Repo-local**: Runs from source (`packages/cli/index.ts`) with workspace
213
+ dependencies resolved by the monorepo
214
+ - **Published**: Runs from dist (`dist/index.ts`) with workspace dependencies
215
+ inlined as nested directories
216
+
217
+ Both versions use the same Bun runtime and TypeScript source files. The build
218
+ process does not transpile; it only restructures the package for standalone use.
@@ -286,6 +286,7 @@ export async function runAgentBindCurrentCommand(
286
286
  ? existing.runtimeBinding
287
287
  : {}),
288
288
  machineId: machine.machineId,
289
+ ownerUserId: userId,
289
290
  },
290
291
  updatedAt: Date.now(),
291
292
  };
@@ -356,6 +357,7 @@ export async function runAgentSmokeCurrentCommand(
356
357
  ? existing.runtimeBinding
357
358
  : {}),
358
359
  machineId: machine.machineId,
360
+ ownerUserId: userId,
359
361
  },
360
362
  updatedAt: Date.now(),
361
363
  },
@@ -0,0 +1,2 @@
1
+ import { ulid } from "ulid";
2
+ export const createAgent = () => ({ id: ulid() });
package/ai/agent.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { ulid } from "ulid";
2
+ export const createAgent = () => ({ id: ulid() });
package/ai/index.ts ADDED
@@ -0,0 +1 @@
1
+ export const test = 1;
package/authCommands.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
3
  import { rmSync } from "node:fs";
4
+ import { spawn } from "node:child_process";
4
5
 
5
6
  import {
6
7
  getCurrentProfile,
@@ -8,40 +9,203 @@ import {
8
9
  loadProfileConfig,
9
10
  saveDefaultProfile,
10
11
  } from "./client/profileConfig";
12
+ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
11
13
 
12
14
  function getArg(args: string[], flag: string) {
13
15
  const index = args.indexOf(flag);
14
16
  return index >= 0 ? args[index + 1] : undefined;
15
17
  }
16
18
 
17
- export async function runLoginCommand(args: string[]) {
18
- const configPath = getDefaultProfileConfigPath();
19
- const serverArg = getArg(args, "--server");
20
- const tokenArg = getArg(args, "--token");
21
- const rl = createInterface({ input, output });
19
+ type LoginCommandDeps = {
20
+ configPath?: string;
21
+ fetchImpl?: typeof fetch;
22
+ openBrowser?: (url: string) => Promise<boolean> | boolean;
23
+ sleep?: (ms: number) => Promise<void>;
24
+ now?: () => number;
25
+ question?: (prompt: string) => Promise<string>;
26
+ output?: Pick<Console, "log">;
27
+ error?: Pick<Console, "error">;
28
+ };
29
+
30
+ const postJson = async (
31
+ fetchImpl: typeof fetch,
32
+ url: string,
33
+ body: Record<string, unknown>
34
+ ) =>
35
+ fetchImpl(url, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+
41
+ const defaultSleep = (ms: number) =>
42
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
43
+
44
+ const defaultOpenBrowser = async (url: string) => {
45
+ const command =
46
+ process.platform === "darwin"
47
+ ? "open"
48
+ : process.platform === "win32"
49
+ ? "cmd"
50
+ : "xdg-open";
51
+ const args =
52
+ process.platform === "win32" ? ["/c", "start", "", url] : [url];
22
53
 
23
54
  try {
24
- const serverUrl = (
25
- serverArg ||
26
- (await rl.question("server [https://nolo.chat]: ")) ||
27
- "https://nolo.chat"
28
- ).replace(/\/+$/, "");
29
-
30
- const authToken = tokenArg || (await rl.question("paste auth token: "));
31
- if (!authToken.trim()) {
32
- console.error("No auth token provided.");
33
- return 1;
55
+ const child = spawn(command, args, {
56
+ detached: true,
57
+ stdio: "ignore",
58
+ });
59
+ child.unref();
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ };
65
+
66
+ async function saveTokenLogin(args: {
67
+ configPath: string;
68
+ serverUrl: string;
69
+ authToken: string;
70
+ output: Pick<Console, "log">;
71
+ error: Pick<Console, "error">;
72
+ }) {
73
+ if (!args.authToken.trim()) {
74
+ args.error.error("No auth token provided.");
75
+ return 1;
76
+ }
77
+
78
+ saveDefaultProfile(args.configPath, {
79
+ serverUrl: args.serverUrl,
80
+ authToken: args.authToken.trim(),
81
+ });
82
+ args.output.log(`Saved profile default -> ${args.serverUrl}`);
83
+ return 0;
84
+ }
85
+
86
+ async function runWebLogin(args: {
87
+ configPath: string;
88
+ serverUrl: string;
89
+ fetchImpl: typeof fetch;
90
+ openBrowser: (url: string) => Promise<boolean> | boolean;
91
+ sleep: (ms: number) => Promise<void>;
92
+ now: () => number;
93
+ output: Pick<Console, "log">;
94
+ error: Pick<Console, "error">;
95
+ noBrowser: boolean;
96
+ }) {
97
+ const startResponse = await postJson(
98
+ args.fetchImpl,
99
+ `${args.serverUrl}/api/v1/users/cli-login/start`,
100
+ { clientName: "nolo-cli" }
101
+ );
102
+ const start = await startResponse.json().catch(() => ({} as any));
103
+ if (!startResponse.ok || !start?.deviceCode || !start?.verificationUriComplete) {
104
+ args.error.error(
105
+ `Failed to start web login (${startResponse.status}). Use --token to paste a token manually.`
106
+ );
107
+ return 1;
108
+ }
109
+
110
+ args.output.log("Open this URL to authorize nolo-cli:");
111
+ args.output.log(start.verificationUriComplete);
112
+ args.output.log(`Code: ${start.userCode}`);
113
+
114
+ if (!args.noBrowser) {
115
+ const opened = await args.openBrowser(start.verificationUriComplete);
116
+ if (!opened) {
117
+ args.output.log("Could not open a browser automatically. Paste the URL above.");
34
118
  }
119
+ }
120
+
121
+ const intervalMs = Math.max(1, Number(start.interval) || 2) * 1000;
122
+ const timeoutMs = Math.max(1, Number(start.expiresIn) || 600) * 1000;
123
+ const deadline = args.now() + timeoutMs;
124
+
125
+ while (args.now() <= deadline) {
126
+ const pollResponse = await postJson(
127
+ args.fetchImpl,
128
+ `${args.serverUrl}/api/v1/users/cli-login/poll`,
129
+ { deviceCode: start.deviceCode }
130
+ );
131
+ const poll = await pollResponse.json().catch(() => ({} as any));
132
+
133
+ if (pollResponse.status === 202) {
134
+ await args.sleep(intervalMs);
135
+ continue;
136
+ }
137
+
138
+ if (pollResponse.ok && poll?.token) {
139
+ const approvedServer =
140
+ typeof poll.serverUrl === "string" && poll.serverUrl.trim()
141
+ ? poll.serverUrl.trim().replace(/\/+$/, "")
142
+ : args.serverUrl;
143
+ return saveTokenLogin({
144
+ configPath: args.configPath,
145
+ serverUrl: approvedServer,
146
+ authToken: poll.token,
147
+ output: args.output,
148
+ error: args.error,
149
+ });
150
+ }
151
+
152
+ args.error.error(
153
+ `Web login failed: ${poll?.error || `HTTP ${pollResponse.status}`}. Use --token to paste a token manually.`
154
+ );
155
+ return 1;
156
+ }
35
157
 
36
- saveDefaultProfile(configPath, {
158
+ args.error.error("Web login timed out. Run `nolo login` again or use --token.");
159
+ return 1;
160
+ }
161
+
162
+ export async function runLoginCommand(args: string[], deps: LoginCommandDeps = {}) {
163
+ const configPath = deps.configPath ?? getDefaultProfileConfigPath();
164
+ const serverArg = getArg(args, "--server");
165
+ const tokenArg = getArg(args, "--token");
166
+ const noBrowser = args.includes("--no-browser");
167
+ const outputTarget = deps.output ?? console;
168
+ const errorTarget = deps.error ?? console;
169
+ const serverUrl = (serverArg || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
170
+
171
+ if (tokenArg) {
172
+ return saveTokenLogin({
173
+ configPath,
37
174
  serverUrl,
38
- authToken: authToken.trim(),
175
+ authToken: tokenArg,
176
+ output: outputTarget,
177
+ error: errorTarget,
39
178
  });
40
- console.log(`Saved profile default -> ${serverUrl}`);
41
- return 0;
42
- } finally {
43
- rl.close();
44
179
  }
180
+
181
+ if (args.includes("--manual")) {
182
+ const rl = createInterface({ input, output });
183
+ const question = deps.question ?? ((prompt: string) => rl.question(prompt));
184
+ try {
185
+ const authToken = await question("paste auth token: ");
186
+ return saveTokenLogin({
187
+ configPath,
188
+ serverUrl,
189
+ authToken,
190
+ output: outputTarget,
191
+ error: errorTarget,
192
+ });
193
+ } finally {
194
+ rl.close();
195
+ }
196
+ }
197
+
198
+ return runWebLogin({
199
+ configPath,
200
+ serverUrl,
201
+ fetchImpl: deps.fetchImpl ?? fetch,
202
+ openBrowser: deps.openBrowser ?? defaultOpenBrowser,
203
+ sleep: deps.sleep ?? defaultSleep,
204
+ now: deps.now ?? Date.now,
205
+ output: outputTarget,
206
+ error: errorTarget,
207
+ noBrowser,
208
+ });
45
209
  }
46
210
 
47
211
  export function runWhoamiCommand() {
@@ -8,10 +8,13 @@ const DB_PATH = "/api/v1/db";
8
8
  /**
9
9
  * Extract userId from a JWT-style auth token without verifying the signature.
10
10
  * Mirrors the logic of `parseToken` in `auth/token.ts` without the crypto imports.
11
+ * @internal - exported for testing only
11
12
  */
12
- function parseTokenUserId(token: string): string | null {
13
+ export function parseTokenUserId(token: string): string | null {
13
14
  try {
14
- const [payloadBase64] = token.split(".");
15
+ const parts = token.split(".");
16
+ if (parts.length < 2) return null;
17
+ const payloadBase64 = parts[1];
15
18
  const payload = JSON.parse(
16
19
  Buffer.from(payloadBase64, "base64").toString("utf8")
17
20
  );
@@ -73,6 +73,8 @@ export function renderHelpText() {
73
73
  " nolo",
74
74
  " nolo chat",
75
75
  " nolo login",
76
+ " nolo login --no-browser",
77
+ " nolo login --token <auth-token>",
76
78
  " nolo whoami",
77
79
  " nolo connect",
78
80
  " nolo connect --watch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nolo-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "description": "Agent-first terminal workspace for Nolo",
6
6
  "bin": {