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 +45 -0
- package/agentRuntimeCommands.ts +2 -0
- package/ai/agent/agentSlice.ts +2 -0
- package/ai/agent.ts +2 -0
- package/ai/index.ts +1 -0
- package/authCommands.ts +185 -21
- package/client/compactDialog.ts +5 -2
- package/commandRegistry.ts +2 -0
- package/package.json +1 -1
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.
|
package/agentRuntimeCommands.ts
CHANGED
|
@@ -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
|
},
|
package/ai/agent.ts
ADDED
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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:
|
|
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() {
|
package/client/compactDialog.ts
CHANGED
|
@@ -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
|
|
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
|
);
|
package/commandRegistry.ts
CHANGED