@north-light/sdk 0.3.479 → 0.3.481

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
@@ -18,7 +18,7 @@ It is a client, not a runtime. It never runs an agent itself, and it does not bu
18
18
  npx @north-light/sdk@latest init my-app
19
19
  ```
20
20
 
21
- `init` writes a runnable app, a signing key and the `northlight` Agent Skill for Claude Code, Codex, Cursor and Copilot, and prints the console steps. `npx @north-light/sdk skill` refreshes the skill after you change the pinned SDK version, and `npx @north-light/sdk env --client-id <client id>` writes the app's env file. None of them needs crtr.
21
+ `init` writes a runnable app, a signing key and the `northlight` Agent Skill for Claude Code, Codex, Cursor and Copilot, and prints the next steps: `npx @north-light/sdk login` (you approve once in the browser), then `npx @north-light/sdk register`, which registers the app and writes its env file. `npx @north-light/sdk skill` refreshes the skill after you change the pinned SDK version. None of them needs crtr.
22
22
 
23
23
  ## Install
24
24
 
@@ -0,0 +1,2 @@
1
+ /** The `crouter-sdk apps <leaf>` command. Returns the process exit code. */
2
+ export declare function runApps(argv: string[]): Promise<number>;
@@ -0,0 +1,119 @@
1
+ import { usageOf } from './cli-usage.js';
2
+ import { callLeaf } from './directory-client.js';
3
+ import { envValueOf } from './env-command.js';
4
+ import { NOT_LOGGED_IN, ENV_PATH, openSession, parseFlags, publicJwkFromFile, readProjectEnv, reportFailure, resolveIssuer } from './developer-common.js';
5
+ const USAGE = usageOf('apps');
6
+ const list = (...names) => names.map((name) => `--${name}`);
7
+ /** The leaves a coding agent's login may call (the directory enforces it too; this table only picks the flags). */
8
+ const LEAVES = {
9
+ 'mine': { path: 'mine', app: false, flags: {} },
10
+ 'get': { path: 'get', app: true, flags: {} },
11
+ 'update': { path: 'update', app: true, flags: { value: list('name', 'origin', 'logo', 'justification') } },
12
+ 'set-redirect-uris': { path: 'set-redirect-uris', app: true, flags: { list: list('redirect-uri') } },
13
+ 'asks': { path: 'asks', app: true, flags: { value: list('group', 'source', 'query'), bool: list('requested') } },
14
+ 'set-scopes': { path: 'set-scopes', app: true, flags: { list: list('scope', 'add', 'remove'), bool: list('clear') } },
15
+ 'set-requires': { path: 'set-requires', app: true, flags: { list: list('provider', 'add', 'remove'), bool: list('clear') } },
16
+ 'set-run-notice-url': { path: 'set-run-notice-url', app: true, flags: { value: list('url'), bool: list('clear') } },
17
+ 'keys list': { path: 'keys/list', app: true, flags: {} },
18
+ 'keys add': { path: 'keys/add', app: true, flags: { value: list('jwk', 'key-file') }, keyFile: true },
19
+ 'keys retire': { path: 'keys/retire', app: true, flags: { value: list('kid') } },
20
+ 'keys delete': { path: 'keys/delete', app: true, flags: { value: list('kid') } },
21
+ 'keys set-jwks-uri': { path: 'keys/set-jwks-uri', app: true, flags: { value: list('jwks-uri') } },
22
+ 'keys remove-jwks-uri': { path: 'keys/remove-jwks-uri', app: true, flags: { value: list('jwk', 'key-file') }, keyFile: true },
23
+ 'testers list': { path: 'testers/list', app: true, flags: {} },
24
+ 'testers add': { path: 'testers/add', app: true, flags: { value: list('email', 'phone') } },
25
+ 'testers remove': { path: 'testers/remove', app: true, flags: { value: list('email', 'phone') } },
26
+ };
27
+ /** What a coding agent's login can't do, by CLI leaf name. */
28
+ const REFUSED = {
29
+ 'set-listing': 'list an app in the marketplace',
30
+ 'set-spend-cap': "change an app's spend cap",
31
+ 'set-allows': 'change what an app shares',
32
+ 'provider get': "read an app's provider role",
33
+ 'provider set': "change an app's provider role",
34
+ 'provider refresh': "refresh an app's provider role",
35
+ };
36
+ const refusal = (leafName, issuer, app) => {
37
+ const appUrl = app ? `${issuer}/console/apps/${app}` : `${issuer}/console`;
38
+ if (leafName === 'delete')
39
+ return `Only you can delete an app, in the console: ${appUrl} → Profile → Delete this app.`;
40
+ return `A coding agent's Northlight login can't ${REFUSED[leafName]}. Do it in the console: ${appUrl}.`;
41
+ };
42
+ /** The `crouter-sdk apps <leaf>` command. Returns the process exit code. */
43
+ export async function runApps(argv) {
44
+ const args = argv.slice(1);
45
+ const words = [];
46
+ while (words.length < 2 && args[0] !== undefined && !args[0].startsWith('--'))
47
+ words.push(args.shift());
48
+ // `keys list` has two words; `mine` has one. Put a second word back when the first is a one-word leaf.
49
+ let leafName = words.join(' ');
50
+ if (words.length === 2 && !(words[0] === 'keys' || words[0] === 'testers' || words[0] === 'provider')) {
51
+ args.unshift(words[1]);
52
+ leafName = words[0];
53
+ }
54
+ const leaf = Object.hasOwn(LEAVES, leafName) ? LEAVES[leafName] : undefined;
55
+ const base = leaf?.flags ?? {};
56
+ const spec = { value: ['--app', '--issuer', ...(base.value ?? [])], list: base.list, bool: ['--json', ...(base.bool ?? [])] };
57
+ let flags;
58
+ try {
59
+ if (!leafName)
60
+ throw new Error(USAGE);
61
+ flags = parseFlags(args, spec, USAGE);
62
+ if (flags.rest.length)
63
+ throw new Error(USAGE);
64
+ }
65
+ catch (error) {
66
+ console.error(error.message);
67
+ return 1;
68
+ }
69
+ const envText = await readProjectEnv();
70
+ const issuer = resolveIssuer(flags.values.get('--issuer'), envText);
71
+ const appId = flags.values.get('--app') ?? (envText === undefined ? undefined : envValueOf(envText, 'CROUTER_CLIENT_ID'));
72
+ if (Object.hasOwn(REFUSED, leafName) || leafName === 'delete') {
73
+ console.error(refusal(leafName, issuer, appId));
74
+ return 1;
75
+ }
76
+ if (!leaf) {
77
+ console.error(`Unknown leaf \`${leafName}\`.\n${USAGE}`);
78
+ return 1;
79
+ }
80
+ if (leaf.app && !appId) {
81
+ console.error(`No app given. Pass --app <client id>, or set CROUTER_CLIENT_ID in ${ENV_PATH}.`);
82
+ return 1;
83
+ }
84
+ const params = {};
85
+ if (leaf.app)
86
+ params['app-id'] = appId;
87
+ for (const [flag, value] of flags.values)
88
+ if (flag !== '--app' && flag !== '--issuer' && flag !== '--key-file')
89
+ params[flag.slice(2)] = value;
90
+ for (const [flag, value] of flags.lists)
91
+ params[flag.slice(2)] = value;
92
+ for (const flag of flags.bools)
93
+ if (flag !== '--json')
94
+ params[flag.slice(2)] = true;
95
+ try {
96
+ const keyFile = flags.values.get('--key-file');
97
+ if (keyFile !== undefined) {
98
+ if (flags.values.has('--jwk')) {
99
+ console.error('Pass --jwk or --key-file, not both.');
100
+ return 1;
101
+ }
102
+ params.jwk = (await publicJwkFromFile(keyFile)).json;
103
+ }
104
+ }
105
+ catch (error) {
106
+ console.error(error.message);
107
+ return 1;
108
+ }
109
+ try {
110
+ const session = await openSession(issuer, NOT_LOGGED_IN);
111
+ if (!session)
112
+ return 1;
113
+ console.log(JSON.stringify(await callLeaf(issuer, session.token.access_token, leaf.path, params), null, 2));
114
+ return 0;
115
+ }
116
+ catch (error) {
117
+ return reportFailure(error);
118
+ }
119
+ }
package/dist/cli-usage.js CHANGED
@@ -29,6 +29,38 @@ export const COMMANDS = [
29
29
  summary: 'Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr.',
30
30
  crtr: 'Only with `--app`',
31
31
  },
32
+ {
33
+ name: 'login',
34
+ forms: ['login [--no-browser] [--new] [--issuer <url>]'],
35
+ summary: 'Log this computer in to your Northlight account: prints a link and a code, you approve once in the browser, and a revocable login is saved to `~/.config/northlight/credentials.json`. Run it again if it times out: it resumes the same code. Calls only the Northlight directory.',
36
+ crtr: 'No',
37
+ },
38
+ {
39
+ name: 'logout',
40
+ forms: ['logout [--issuer <url>]'],
41
+ summary: 'Revoke this computer\'s login on the server and delete it locally.',
42
+ crtr: 'No',
43
+ },
44
+ {
45
+ name: 'whoami',
46
+ forms: ['whoami [--issuer <url>]'],
47
+ summary: 'Show the account this computer is logged in as and when the login expires. Exits 1 when not logged in.',
48
+ crtr: 'No',
49
+ },
50
+ {
51
+ name: 'register',
52
+ forms: ['register [--name <text>] [--scope <scope>]... [--logo <url>] [--justification <text>] [--redirect-uri <uri>]... [--issuer <url>]'],
53
+ summary: 'Run in a project made by `init`, after `login`: registers the app with the public key from `local/app-key.json`, adds each `--scope` beyond `crtr:llm crtr:act`, and writes `CROUTER_CLIENT_ID` and `CROUTER_ISSUER` to `local/app.env`.',
54
+ crtr: 'No',
55
+ },
56
+ {
57
+ name: 'apps',
58
+ forms: [
59
+ 'apps <leaf> [--app <client id>] [--issuer <url>] [leaf flags] [--json]',
60
+ ],
61
+ summary: 'Call one app-management leaf with the login and print its answer as JSON. Leaves: `mine`, `get`, `update`, `set-redirect-uris`, `asks`, `set-scopes`, `set-requires`, `set-run-notice-url`, `keys list|add|retire|delete|set-jwks-uri|remove-jwks-uri`, `testers list|add|remove`. `--app` defaults to `CROUTER_CLIENT_ID` in `local/app.env`; `keys add` takes `--key-file <path>` and sends only the public half. Deleting an app, its marketplace listing, spend cap, sharing and provider role are console-only.',
62
+ crtr: 'No',
63
+ },
32
64
  ];
33
65
  const BIN = 'crouter-sdk';
34
66
  /** The usage text a command prints on a usage error. */
@@ -0,0 +1,36 @@
1
+ export interface PendingLogin {
2
+ /** `nld_…`, generated by the CLI and written before the start request is sent. */
3
+ secret: string;
4
+ name: string;
5
+ started_at: string;
6
+ device_code?: string;
7
+ user_code?: string;
8
+ verification_uri_complete?: string;
9
+ expires_at?: string;
10
+ }
11
+ export interface IssuerEntry {
12
+ credential?: string;
13
+ credential_id?: string;
14
+ name?: string;
15
+ account_id?: string;
16
+ account_display?: string;
17
+ expires_at?: string;
18
+ pending?: PendingLogin;
19
+ }
20
+ export interface CredentialStore {
21
+ version: 1;
22
+ issuers: Record<string, IssuerEntry>;
23
+ }
24
+ export declare class CredentialFileError extends Error {
25
+ }
26
+ export declare const issuerKey: (issuer: string) => string;
27
+ export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
28
+ /** `~/…` when the path is under the home directory. */
29
+ export declare function displayPath(path: string): string;
30
+ /** A fresh credential secret: `nld_` and 43 base64url characters (32 random bytes). */
31
+ export declare const newSecret: () => string;
32
+ export declare function loadStore(path?: string): Promise<CredentialStore>;
33
+ /** Writes the store atomically (temporary file at 0600, then rename); the directory is created at 0700. */
34
+ export declare function saveStore(store: CredentialStore, path?: string): Promise<void>;
35
+ /** Replaces one issuer's entry (or removes it when `entry` is undefined or empty) and saves. */
36
+ export declare function updateEntry(issuer: string, change: (entry: IssuerEntry) => IssuerEntry | undefined, path?: string): Promise<IssuerEntry | undefined>;
@@ -0,0 +1,71 @@
1
+ // The developer login store: ~/.config/northlight/credentials.json (or $XDG_CONFIG_HOME/northlight/), per issuer, mode 0600.
2
+ // It does no network I/O. The pending entry holds the secret before the directory knows anything, so a killed `login` can resume.
3
+ import { randomBytes } from 'node:crypto';
4
+ import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ export class CredentialFileError extends Error {
8
+ }
9
+ export const issuerKey = (issuer) => issuer.replace(/\/+$/, '');
10
+ export function credentialsPath(env = process.env) {
11
+ const base = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME !== '' ? env.XDG_CONFIG_HOME : join(env.HOME && env.HOME !== '' ? env.HOME : homedir(), '.config');
12
+ return join(base, 'northlight', 'credentials.json');
13
+ }
14
+ /** `~/…` when the path is under the home directory. */
15
+ export function displayPath(path) {
16
+ const home = process.env.HOME && process.env.HOME !== '' ? process.env.HOME : homedir();
17
+ return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
18
+ }
19
+ /** A fresh credential secret: `nld_` and 43 base64url characters (32 random bytes). */
20
+ export const newSecret = () => `nld_${randomBytes(32).toString('base64url')}`;
21
+ export async function loadStore(path = credentialsPath()) {
22
+ let info;
23
+ try {
24
+ info = await stat(path);
25
+ }
26
+ catch (error) {
27
+ if (error.code === 'ENOENT')
28
+ return { version: 1, issuers: {} };
29
+ throw error;
30
+ }
31
+ if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
32
+ throw new CredentialFileError(`${displayPath(path)} can be read by other users (mode ${(info.mode & 0o777).toString(8)}), so it was not used. Run \`chmod 600 ${path}\`, or delete it and run \`npx @north-light/sdk login\`.`);
33
+ }
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(await readFile(path, 'utf8'));
37
+ }
38
+ catch {
39
+ throw new CredentialFileError(`${displayPath(path)} is not valid JSON. Delete it and run \`npx @north-light/sdk login\`.`);
40
+ }
41
+ const issuers = parsed?.issuers;
42
+ if (!issuers || typeof issuers !== 'object')
43
+ return { version: 1, issuers: {} };
44
+ return { version: 1, issuers: issuers };
45
+ }
46
+ /** Writes the store atomically (temporary file at 0600, then rename); the directory is created at 0700. */
47
+ export async function saveStore(store, path = credentialsPath()) {
48
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
49
+ const temporary = `${path}.${process.pid}.tmp`;
50
+ try {
51
+ await writeFile(temporary, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
52
+ await chmod(temporary, 0o600);
53
+ await rename(temporary, path);
54
+ }
55
+ catch (error) {
56
+ await unlink(temporary).catch(() => undefined);
57
+ throw error;
58
+ }
59
+ }
60
+ /** Replaces one issuer's entry (or removes it when `entry` is undefined or empty) and saves. */
61
+ export async function updateEntry(issuer, change, path = credentialsPath()) {
62
+ const store = await loadStore(path);
63
+ const key = issuerKey(issuer);
64
+ const next = change({ ...store.issuers[key] });
65
+ if (next && Object.keys(next).length > 0)
66
+ store.issuers[key] = next;
67
+ else
68
+ delete store.issuers[key];
69
+ await saveStore(store, path);
70
+ return store.issuers[key];
71
+ }
@@ -0,0 +1,47 @@
1
+ import { type IssuerEntry } from './credentials.js';
2
+ import { DirectoryError, type TokenAnswer } from './directory-client.js';
3
+ export declare const ENV_PATH = "local/app.env";
4
+ export declare const DEFAULT_KEY_FILE = "local/app-key.json";
5
+ export declare const LOGIN_COMMAND = "npx @north-light/sdk login";
6
+ export interface FlagSpec {
7
+ value?: string[];
8
+ list?: string[];
9
+ bool?: string[];
10
+ }
11
+ export interface Flags {
12
+ values: Map<string, string>;
13
+ lists: Map<string, string[]>;
14
+ bools: Set<string>;
15
+ rest: string[];
16
+ }
17
+ /** Parses `--name value`, repeatable `--name value`, and `--name` flags; anything else is returned in `rest`. Throws `usage` on a bad flag. */
18
+ export declare function parseFlags(args: string[], spec: FlagSpec, usage: string): Flags;
19
+ export declare const trimIssuer: (issuer: string) => string;
20
+ /** The project's local/app.env text, or undefined when there is none. */
21
+ export declare const readProjectEnv: () => Promise<string | undefined>;
22
+ /** `--issuer`, else (for commands that read the project) `CROUTER_ISSUER` from local/app.env, else production. */
23
+ export declare function resolveIssuer(flag: string | undefined, envText?: string): string;
24
+ /** The public half of a private JWK: every private member dropped. */
25
+ export declare function publicJwkOf(privateJwk: Record<string, unknown>): Record<string, unknown>;
26
+ /** Reads a private key file and returns its public JWK (as JSON text) and key id. The private half is never returned. */
27
+ export declare function publicJwkFromFile(path: string): Promise<{
28
+ json: string;
29
+ kid: string;
30
+ }>;
31
+ /** `<code>: <message>` then `Next: <next>`, the way every directory error is printed. */
32
+ export declare function directoryErrorLines(error: DirectoryError): string[];
33
+ export declare const NOT_LOGGED_IN = "Not logged in. Run `npx @north-light/sdk login`.";
34
+ /** Prints the message for a failure every command shares and returns exit code 1; rethrows anything else. */
35
+ export declare function reportFailure(error: unknown): number;
36
+ export interface Session {
37
+ issuer: string;
38
+ entry: IssuerEntry & {
39
+ credential: string;
40
+ };
41
+ token: TokenAnswer;
42
+ }
43
+ /**
44
+ * Loads the issuer's credential and exchanges it for a 5-minute token. Returns undefined, having printed why
45
+ * (`missing` is the message for no credential), when the command cannot go on. `onRejected` runs on a 401.
46
+ */
47
+ export declare function openSession(issuer: string, missing: string, onRejected?: () => Promise<void>): Promise<Session | undefined>;
@@ -0,0 +1,112 @@
1
+ // What the developer commands (login, logout, whoami, register, apps) share: flag parsing, issuer resolution, the
2
+ // project's env file, the key file's public half, and the sign-in step with its error output.
3
+ import { readFile } from 'node:fs/promises';
4
+ import { CredentialFileError, issuerKey, loadStore } from './credentials.js';
5
+ import { DirectoryError, NetworkError, exchange } from './directory-client.js';
6
+ import { DEFAULT_ISSUER, envValueOf } from './env-command.js';
7
+ import { parsePrivateJwk } from './oauth/keygen.js';
8
+ export const ENV_PATH = 'local/app.env';
9
+ export const DEFAULT_KEY_FILE = 'local/app-key.json';
10
+ export const LOGIN_COMMAND = 'npx @north-light/sdk login';
11
+ /** Parses `--name value`, repeatable `--name value`, and `--name` flags; anything else is returned in `rest`. Throws `usage` on a bad flag. */
12
+ export function parseFlags(args, spec, usage) {
13
+ const out = { values: new Map(), lists: new Map(), bools: new Set(), rest: [] };
14
+ for (let i = 0; i < args.length; i++) {
15
+ const arg = args[i];
16
+ if (!arg.startsWith('--')) {
17
+ out.rest.push(arg);
18
+ continue;
19
+ }
20
+ if (spec.bool?.includes(arg)) {
21
+ out.bools.add(arg);
22
+ continue;
23
+ }
24
+ const isValue = spec.value?.includes(arg);
25
+ const isList = spec.list?.includes(arg);
26
+ const next = args[++i];
27
+ if ((!isValue && !isList) || next === undefined || next.startsWith('--') || next === '')
28
+ throw new Error(usage);
29
+ if (isValue) {
30
+ if (out.values.has(arg))
31
+ throw new Error(usage);
32
+ out.values.set(arg, next);
33
+ }
34
+ else
35
+ out.lists.set(arg, [...(out.lists.get(arg) ?? []), next]);
36
+ }
37
+ return out;
38
+ }
39
+ export const trimIssuer = (issuer) => issuerKey(issuer);
40
+ /** The project's local/app.env text, or undefined when there is none. */
41
+ export const readProjectEnv = () => readFile(ENV_PATH, 'utf8').catch((error) => {
42
+ if (error.code === 'ENOENT')
43
+ return undefined;
44
+ throw error;
45
+ });
46
+ /** `--issuer`, else (for commands that read the project) `CROUTER_ISSUER` from local/app.env, else production. */
47
+ export function resolveIssuer(flag, envText) {
48
+ return trimIssuer(flag ?? (envText === undefined ? undefined : envValueOf(envText, 'CROUTER_ISSUER')) ?? DEFAULT_ISSUER);
49
+ }
50
+ /** The public half of a private JWK: every private member dropped. */
51
+ export function publicJwkOf(privateJwk) {
52
+ const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, oth: _oth, k: _k, ...rest } = privateJwk;
53
+ return rest;
54
+ }
55
+ /** Reads a private key file and returns its public JWK (as JSON text) and key id. The private half is never returned. */
56
+ export async function publicJwkFromFile(path) {
57
+ let text;
58
+ try {
59
+ text = await readFile(path, 'utf8');
60
+ }
61
+ catch (error) {
62
+ throw new Error(`${path} could not be read (${error instanceof Error ? error.message : String(error)}). Make one with \`npx @north-light/sdk keygen --out ${path}\`.`);
63
+ }
64
+ const key = parsePrivateJwk(text, path);
65
+ return { json: JSON.stringify(publicJwkOf(key)), kid: key.kid };
66
+ }
67
+ /** `<code>: <message>` then `Next: <next>`, the way every directory error is printed. */
68
+ export function directoryErrorLines(error) {
69
+ return [`${error.code}: ${error.message}`, ...(error.next ? [`Next: ${error.next}`] : [])];
70
+ }
71
+ export const NOT_LOGGED_IN = `Not logged in. Run \`${LOGIN_COMMAND}\`.`;
72
+ /** Prints the message for a failure every command shares and returns exit code 1; rethrows anything else. */
73
+ export function reportFailure(error) {
74
+ if (error instanceof NetworkError) {
75
+ console.error(error.message);
76
+ return 1;
77
+ }
78
+ if (error instanceof DirectoryError) {
79
+ console.error(directoryErrorLines(error).join('\n'));
80
+ return 1;
81
+ }
82
+ if (error instanceof CredentialFileError) {
83
+ console.error(error.message);
84
+ return 1;
85
+ }
86
+ throw error;
87
+ }
88
+ /**
89
+ * Loads the issuer's credential and exchanges it for a 5-minute token. Returns undefined, having printed why
90
+ * (`missing` is the message for no credential), when the command cannot go on. `onRejected` runs on a 401.
91
+ */
92
+ export async function openSession(issuer, missing, onRejected) {
93
+ const store = await loadStore();
94
+ const entry = store.issuers[issuer];
95
+ if (!entry?.credential) {
96
+ console.error(missing);
97
+ return undefined;
98
+ }
99
+ try {
100
+ const token = await exchange(issuer, entry.credential);
101
+ return { issuer, entry: entry, token };
102
+ }
103
+ catch (error) {
104
+ if (error instanceof DirectoryError && error.status === 401) {
105
+ console.error(error.message);
106
+ await onRejected?.();
107
+ return undefined;
108
+ }
109
+ reportFailure(error);
110
+ return undefined;
111
+ }
112
+ }
@@ -0,0 +1,46 @@
1
+ export declare class DirectoryError extends Error {
2
+ readonly status: number;
3
+ readonly code: string;
4
+ readonly next?: string | undefined;
5
+ constructor(status: number, code: string, message: string, next?: string | undefined);
6
+ }
7
+ export declare class NetworkError extends Error {
8
+ readonly issuer: string;
9
+ readonly reason: string;
10
+ constructor(issuer: string, reason: string);
11
+ }
12
+ export declare const sha256Hex: (text: string) => string;
13
+ export interface Account {
14
+ id: string;
15
+ display: string;
16
+ }
17
+ export interface LoginStart {
18
+ device_code: string;
19
+ user_code: string;
20
+ verification_uri: string;
21
+ verification_uri_complete: string;
22
+ expires_in: number;
23
+ interval: number;
24
+ }
25
+ export type PollResult = {
26
+ status: 'pending' | 'slow_down' | 'access_denied' | 'expired_token';
27
+ } | {
28
+ status: 'approved';
29
+ credential_id: string;
30
+ account: Account;
31
+ expires_at: string;
32
+ idle_days: number;
33
+ };
34
+ export interface TokenAnswer {
35
+ access_token: string;
36
+ credential_id: string;
37
+ account: Account;
38
+ expires_at: string;
39
+ }
40
+ export declare function startLogin(issuer: string, secret: string, name: string): Promise<LoginStart>;
41
+ export declare function pollLogin(issuer: string, deviceCode: string): Promise<PollResult>;
42
+ export declare function exchange(issuer: string, secret: string): Promise<TokenAnswer>;
43
+ export declare function revokeCredential(issuer: string, secret: string): Promise<void>;
44
+ /** One developer leaf of the `cloud` plugin: `leaf` is its path under `apps`, `params` the leaf's kebab-case parameters. */
45
+ export declare function callLeaf(issuer: string, accessToken: string, leaf: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
46
+ export declare const machineName: () => string;
@@ -0,0 +1,105 @@
1
+ // Every HTTP call the developer CLI makes to the directory. It maps directory errors to DirectoryError and network
2
+ // failures to NetworkError; it owns no output text beyond those messages.
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { hostname, platform } from 'node:os';
5
+ import { SDK_VERSION } from './version.js';
6
+ export class DirectoryError extends Error {
7
+ status;
8
+ code;
9
+ next;
10
+ constructor(status, code, message, next) {
11
+ super(message);
12
+ this.status = status;
13
+ this.code = code;
14
+ this.next = next;
15
+ }
16
+ }
17
+ export class NetworkError extends Error {
18
+ issuer;
19
+ reason;
20
+ constructor(issuer, reason) {
21
+ super(`Couldn't reach ${issuer} (${reason}). To register without logging in, use the console: ${issuer}/console/new`);
22
+ this.issuer = issuer;
23
+ this.reason = reason;
24
+ }
25
+ }
26
+ export const sha256Hex = (text) => createHash('sha256').update(text).digest('hex');
27
+ function errorOf(body, status) {
28
+ const record = (body && typeof body === 'object' ? body : {});
29
+ const error = record.error;
30
+ if (error && typeof error === 'object') {
31
+ const e = error;
32
+ return { code: String(e.code ?? 'error'), message: String(e.message ?? `HTTP ${status}`), ...(typeof e.next === 'string' ? { next: e.next } : {}) };
33
+ }
34
+ if (typeof error === 'string')
35
+ return { code: error, message: String(record.error_description ?? record.message ?? error) };
36
+ return { code: 'error', message: `The directory answered HTTP ${status}.` };
37
+ }
38
+ async function request(issuer, path, init) {
39
+ let response;
40
+ try {
41
+ response = await fetch(`${issuer}${path}`, {
42
+ method: 'POST',
43
+ headers: { 'content-type': 'application/json', ...init.headers },
44
+ body: JSON.stringify(init.body ?? {}),
45
+ signal: AbortSignal.timeout(30_000),
46
+ });
47
+ }
48
+ catch (error) {
49
+ const cause = error.cause;
50
+ throw new NetworkError(issuer, cause?.code ?? cause?.message ?? (error instanceof Error ? error.message : String(error)));
51
+ }
52
+ const text = await response.text();
53
+ let body;
54
+ try {
55
+ body = text === '' ? {} : JSON.parse(text);
56
+ }
57
+ catch {
58
+ body = {};
59
+ }
60
+ return { status: response.status, body };
61
+ }
62
+ const fail = (status, body) => {
63
+ const e = errorOf(body, status);
64
+ throw new DirectoryError(status, e.code, e.message, e.next);
65
+ };
66
+ export async function startLogin(issuer, secret, name) {
67
+ const { status, body } = await request(issuer, '/v1/developer/logins', { body: {
68
+ credential_hash: sha256Hex(secret), name: name.slice(0, 64),
69
+ platform: platform() === 'darwin' || platform() === 'win32' ? platform() : 'linux', sdk_version: SDK_VERSION,
70
+ } });
71
+ if (status !== 200 && status !== 201)
72
+ fail(status, body);
73
+ return body;
74
+ }
75
+ export async function pollLogin(issuer, deviceCode) {
76
+ const { status, body } = await request(issuer, '/v1/developer/logins/poll', { body: { device_code: deviceCode } });
77
+ if (status === 200)
78
+ return { ...body, status: 'approved' };
79
+ const code = errorOf(body, status).code;
80
+ if (status === 400 && (code === 'authorization_pending' || code === 'slow_down' || code === 'access_denied' || code === 'expired_token')) {
81
+ return { status: code === 'authorization_pending' ? 'pending' : code };
82
+ }
83
+ return fail(status, body);
84
+ }
85
+ export async function exchange(issuer, secret) {
86
+ const { status, body } = await request(issuer, '/v1/developer/token', { headers: { authorization: `Bearer ${secret}` } });
87
+ if (status !== 200)
88
+ fail(status, body);
89
+ return body;
90
+ }
91
+ export async function revokeCredential(issuer, secret) {
92
+ const { status, body } = await request(issuer, '/v1/developer/token/revoke', { headers: { authorization: `Bearer ${secret}` } });
93
+ if (status !== 200)
94
+ fail(status, body);
95
+ }
96
+ /** One developer leaf of the `cloud` plugin: `leaf` is its path under `apps`, `params` the leaf's kebab-case parameters. */
97
+ export async function callLeaf(issuer, accessToken, leaf, params) {
98
+ const { status, body } = await request(issuer, `/v1/plugin/v1/cloud/apps/${leaf}`, {
99
+ headers: { authorization: `Bearer ${accessToken}`, 'crtr-request-id': randomUUID() }, body: params,
100
+ });
101
+ if (status !== 200)
102
+ fail(status, body);
103
+ return body;
104
+ }
105
+ export const machineName = () => hostname().slice(0, 64);
@@ -11,6 +11,7 @@ import { createKeyFile } from './keygen-command.js';
11
11
  import { isAgent, packagedSkill, skillTargets, writeSkillFile } from './skill-command.js';
12
12
  import { SDK_VERSION } from './version.js';
13
13
  const CONSOLE_URL = 'https://account.northlight.ai/console/new';
14
+ const QUICKSTART_URL = 'https://sdk.docs.northlight.ai/docs/quickstart';
14
15
  const KEY_FILE = 'local/app-key.json';
15
16
  const ENV_FILE = 'local/app.env';
16
17
  const START = 'node --env-file=local/app.env --import tsx server.ts';
@@ -93,6 +94,41 @@ function installSdk(cwd) {
93
94
  }
94
95
  return result.status ?? 1;
95
96
  }
97
+ /** The "Next" text when `register` can run: log in, register, start. */
98
+ function agentNext(cd, existingProject) {
99
+ return [
100
+ 'Next, in this order:',
101
+ " 1. Sign up at https://account.northlight.ai/signup if you don't have an account.",
102
+ ' 2. npx @north-light/sdk login (you approve once in the browser)',
103
+ ' 3. npx @north-light/sdk register --scope sms:send',
104
+ ' 4. Start it:',
105
+ ...cd.map((line) => ` ${line}`),
106
+ ...(existingProject ? [] : [' npm i']),
107
+ ` ${START}`,
108
+ ` then open ${DEFAULT_ORIGIN}/auth/login`,
109
+ `To register in the browser instead: ${CONSOLE_URL} (see ${QUICKSTART_URL}).`,
110
+ ];
111
+ }
112
+ /** The "Next" text for an existing project with no local/app.env, where `register` has no origin to read: the console path. */
113
+ function consoleNext(cd, existingProject, jwkOnStdout) {
114
+ return [
115
+ 'Next, in this order:',
116
+ " 1. Sign up at https://account.northlight.ai/signup if you don't have an account.",
117
+ ` 2. Register the app at ${CONSOLE_URL} with these values:`,
118
+ ` Origin URL: ${DEFAULT_ORIGIN}`,
119
+ ` Redirect URI: ${DEFAULT_ORIGIN}/auth/callback`,
120
+ ...(jwkOnStdout ? [' Public key: the JWK printed on stdout'] : [` Public key: the public half of ${KEY_FILE}`]),
121
+ ' Then open the app\'s "What it asks for" tab, choose Add permissions, and add sms:send.',
122
+ ' 3. Write the client ID the console shows into the env file:',
123
+ ' npx @north-light/sdk env --client-id <client id>',
124
+ ' 4. Start it:',
125
+ ...cd.map((line) => ` ${line}`),
126
+ ...(existingProject ? [] : [' npm i']),
127
+ ` ${START}`,
128
+ ` then open ${DEFAULT_ORIGIN}/auth/login`,
129
+ `Steps: ${QUICKSTART_URL}`,
130
+ ];
131
+ }
96
132
  /** The `crouter-sdk init` command. Returns the process exit code. */
97
133
  export async function runInit(argv, options = {}) {
98
134
  const { dir: given, agent, globalSkill } = parseArgs(argv);
@@ -152,6 +188,8 @@ export async function runInit(argv, options = {}) {
152
188
  for (const path of skillPaths)
153
189
  await writeSkillFile(path, skill);
154
190
  const cd = given === '.' ? [] : [`cd ${given}`];
191
+ // `register` reads APP_ORIGIN from local/app.env, which init writes only for a new app.
192
+ const registerable = await exists(join(root, ENV_FILE));
155
193
  say([
156
194
  existingProject ? `Added @north-light/sdk@${SDK_VERSION}, a signing key and the \`northlight\` skill to ${shown(root)}.` : `Created a Northlight app in ${shown(root)}:`,
157
195
  ...(existingProject ? [] : [' package.json server.ts .gitignore local/app.env', '']),
@@ -160,20 +198,7 @@ export async function runInit(argv, options = {}) {
160
198
  'Skill:',
161
199
  ...skillPaths.map((path) => ` ${globalSkill ? path : shown(path)}`),
162
200
  '',
163
- 'Next, in this order:',
164
- " 1. Sign in at https://account.northlight.ai/signup if you don't have an account.",
165
- ` 2. Register the app at ${CONSOLE_URL} with these values:`,
166
- ` Origin URL: ${DEFAULT_ORIGIN}`,
167
- ` Redirect URI: ${DEFAULT_ORIGIN}/auth/callback`,
168
- ...(publicJwk ? [' Public key: the JWK printed on stdout'] : [` Public key: the public half of ${KEY_FILE}`]),
169
- ' Then open the app\'s "What it asks for" tab, choose Add permissions, and add sms:send.',
170
- ' 3. Write the client ID the console shows into the env file:',
171
- ' npx @north-light/sdk env --client-id <client id>',
172
- ' 4. Start it:',
173
- ...cd.map((line) => ` ${line}`),
174
- ...(existingProject ? [] : [' npm i']),
175
- ` ${START}`,
176
- ` then open ${DEFAULT_ORIGIN}/auth/login`,
201
+ ...(registerable ? agentNext(cd, existingProject) : consoleNext(cd, existingProject, publicJwk !== undefined)),
177
202
  ]);
178
203
  if (publicJwk)
179
204
  console.log(publicJwk);
@@ -1,14 +1,19 @@
1
1
  #!/usr/bin/env node
2
- // The `crouter-sdk` bin: `init`, `skill`, `keygen` and `env`. The file keeps its first command's name because the
2
+ // The `crouter-sdk` bin: `init`, `skill`, `keygen`, `env`, and the developer commands `login`, `logout`, `whoami`, `register` and `apps`. The file keeps its first command's name because the
3
3
  // published bin path (`dist/keygen-cli.js`) is part of the package, and the package keeps exactly one bin so that
4
4
  // `npx @north-light/sdk <command>` works without naming it.
5
5
  import { overviewUsage } from './cli-usage.js';
6
6
  import { runEnv } from './env-command.js';
7
7
  import { runInit } from './init-command.js';
8
8
  import { runKeygen } from './keygen-command.js';
9
+ import { runApps } from './apps-command.js';
10
+ import { runLogin } from './login-command.js';
11
+ import { runLogout } from './logout-command.js';
12
+ import { runRegister } from './register-command.js';
13
+ import { runWhoami } from './whoami-command.js';
9
14
  import { runSkill } from './skill-command.js';
10
15
  const args = process.argv.slice(2);
11
- const commands = { init: runInit, skill: runSkill, keygen: runKeygen, env: runEnv };
16
+ const commands = { init: runInit, skill: runSkill, keygen: runKeygen, env: runEnv, login: runLogin, logout: runLogout, whoami: runWhoami, register: runRegister, apps: runApps };
12
17
  const command = args[0] ?? '';
13
18
  async function main() {
14
19
  if (command === '--help' || command === '-h' || command === 'help') {
@@ -0,0 +1 @@
1
+ export declare function runLogin(argv: string[]): Promise<number>;
@@ -0,0 +1,142 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { loadStore, newSecret, displayPath, credentialsPath, updateEntry } from './credentials.js';
3
+ import { usageOf } from './cli-usage.js';
4
+ import { NetworkError, DirectoryError, machineName, pollLogin, revokeCredential, startLogin } from './directory-client.js';
5
+ import { parseFlags, reportFailure, resolveIssuer } from './developer-common.js';
6
+ const USAGE = usageOf('login');
7
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8
+ /** The poll interval in ms. The directory says 5 s; tests shorten it with NORTHLIGHT_LOGIN_POLL_MS. */
9
+ const intervalMs = () => Number(process.env.NORTHLIGHT_LOGIN_POLL_MS) || 5000;
10
+ /** Tries to open `url` in a browser, detached. Resolves true when the opener started. */
11
+ function openBrowser(url) {
12
+ if (process.env.CI)
13
+ return Promise.resolve(false);
14
+ let command;
15
+ let args;
16
+ if (process.platform === 'darwin') {
17
+ command = 'open';
18
+ args = [url];
19
+ }
20
+ else if (process.platform === 'win32') {
21
+ command = 'cmd';
22
+ args = ['/c', 'start', '""', url];
23
+ }
24
+ else if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
25
+ command = 'xdg-open';
26
+ args = [url];
27
+ }
28
+ else
29
+ return Promise.resolve(false);
30
+ return new Promise((resolve) => {
31
+ try {
32
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
33
+ child.once('error', () => resolve(false));
34
+ child.once('spawn', () => { child.unref(); resolve(true); });
35
+ }
36
+ catch {
37
+ resolve(false);
38
+ }
39
+ });
40
+ }
41
+ const hhmm = (iso) => {
42
+ const date = new Date(iso);
43
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
44
+ };
45
+ export async function runLogin(argv) {
46
+ let flags;
47
+ try {
48
+ flags = parseFlags(argv.slice(1), { value: ['--issuer'], bool: ['--no-browser', '--new'] }, USAGE);
49
+ if (flags.rest.length)
50
+ throw new Error(USAGE);
51
+ }
52
+ catch (error) {
53
+ console.error(error.message);
54
+ return 1;
55
+ }
56
+ const issuer = resolveIssuer(flags.values.get('--issuer'));
57
+ try {
58
+ const store = await loadStore();
59
+ let pending = store.issuers[issuer]?.pending;
60
+ const now = Date.now();
61
+ let resumed = false;
62
+ if (pending && (flags.bools.has('--new') || !pending.device_code || !pending.expires_at || Date.parse(pending.expires_at) <= now)) {
63
+ pending = undefined;
64
+ await updateEntry(issuer, ({ pending: _gone, ...rest }) => rest);
65
+ }
66
+ else if (pending)
67
+ resumed = true;
68
+ if (!pending) {
69
+ const fresh = { secret: newSecret(), name: machineName(), started_at: new Date().toISOString() };
70
+ // The secret is on disk before the directory hears anything about it.
71
+ await updateEntry(issuer, (entry) => ({ ...entry, pending: fresh }));
72
+ let start;
73
+ try {
74
+ start = await startLogin(issuer, fresh.secret, fresh.name);
75
+ }
76
+ catch (error) {
77
+ if (error instanceof DirectoryError && error.status === 404) {
78
+ await updateEntry(issuer, ({ pending: _gone, ...rest }) => rest);
79
+ console.error(`This Northlight directory doesn't support login yet. Register in the console: ${issuer}/console/new`);
80
+ return 1;
81
+ }
82
+ throw error;
83
+ }
84
+ pending = { ...fresh, device_code: start.device_code, user_code: start.user_code,
85
+ verification_uri_complete: start.verification_uri_complete, expires_at: new Date(Date.now() + start.expires_in * 1000).toISOString() };
86
+ const saved = pending;
87
+ await updateEntry(issuer, (entry) => ({ ...entry, pending: saved }));
88
+ }
89
+ const url = pending.verification_uri_complete;
90
+ const code = pending.user_code;
91
+ if (resumed)
92
+ console.log(`Still waiting for the login started at ${hhmm(pending.started_at)}.`);
93
+ console.log(['To let this computer manage your Northlight apps, open:', '', ` ${url}`, '', `and check the page shows the code ${code}.`].join('\n'));
94
+ const opened = !flags.bools.has('--no-browser') && await openBrowser(url);
95
+ console.log(opened ? 'Opened it in your browser.' : "Couldn't open a browser here. Open the link on any device where you can sign in to Northlight.");
96
+ const minutes = Math.max(1, Math.round((Date.parse(pending.expires_at) - Date.now()) / 60_000));
97
+ console.log(`Waiting for approval (the code expires in ${minutes} minute${minutes === 1 ? '' : 's'})…`);
98
+ let wait = intervalMs();
99
+ const deadline = Date.parse(pending.expires_at) + 30_000;
100
+ for (;;) {
101
+ const answer = await pollLogin(issuer, pending.device_code);
102
+ if (answer.status === 'approved') {
103
+ const old = (await loadStore()).issuers[issuer]?.credential;
104
+ await updateEntry(issuer, () => ({
105
+ credential: pending.secret, credential_id: answer.credential_id, name: pending.name,
106
+ account_id: answer.account.id, account_display: answer.account.display, expires_at: answer.expires_at,
107
+ }));
108
+ if (old && old !== pending.secret)
109
+ await revokeCredential(issuer, old).catch(() => undefined);
110
+ console.log([
111
+ `Signed in as ${answer.account.display} (${answer.account.id}).`,
112
+ `Saved the login for ${pending.name} to ${displayPath(credentialsPath())} (only you can read it).`,
113
+ "It can register and configure the apps you manage. It can't delete an app.",
114
+ `It expires on ${answer.expires_at.slice(0, 10)}, or after ${answer.idle_days ?? 14} days unused. Revoke it at ${issuer}/account/developer`,
115
+ ].join('\n'));
116
+ return 0;
117
+ }
118
+ if (answer.status === 'access_denied' || answer.status === 'expired_token') {
119
+ await updateEntry(issuer, ({ pending: _gone, ...rest }) => rest);
120
+ console.error(answer.status === 'access_denied'
121
+ ? 'The login was declined in the browser. Nothing was saved.'
122
+ : 'The code expired before it was approved. Run `npx @north-light/sdk login` again.');
123
+ return 1;
124
+ }
125
+ if (Date.now() > deadline) {
126
+ await updateEntry(issuer, ({ pending: _gone, ...rest }) => rest);
127
+ console.error('The code expired before it was approved. Run `npx @north-light/sdk login` again.');
128
+ return 1;
129
+ }
130
+ if (answer.status === 'slow_down')
131
+ wait += intervalMs();
132
+ await sleep(wait);
133
+ }
134
+ }
135
+ catch (error) {
136
+ if (error instanceof NetworkError) {
137
+ console.error(error.message);
138
+ return 1;
139
+ }
140
+ return reportFailure(error);
141
+ }
142
+ }
@@ -0,0 +1 @@
1
+ export declare function runLogout(argv: string[]): Promise<number>;
@@ -0,0 +1,42 @@
1
+ import { NetworkError, DirectoryError, revokeCredential } from './directory-client.js';
2
+ import { loadStore, updateEntry } from './credentials.js';
3
+ import { usageOf } from './cli-usage.js';
4
+ import { NOT_LOGGED_IN, parseFlags, reportFailure, resolveIssuer } from './developer-common.js';
5
+ export async function runLogout(argv) {
6
+ let flags;
7
+ try {
8
+ flags = parseFlags(argv.slice(1), { value: ['--issuer'] }, usageOf('logout'));
9
+ if (flags.rest.length)
10
+ throw new Error(usageOf('logout'));
11
+ }
12
+ catch (error) {
13
+ console.error(error.message);
14
+ return 1;
15
+ }
16
+ const issuer = resolveIssuer(flags.values.get('--issuer'));
17
+ try {
18
+ const entry = (await loadStore()).issuers[issuer];
19
+ if (!entry?.credential) {
20
+ console.error(NOT_LOGGED_IN);
21
+ return 1;
22
+ }
23
+ const name = entry.name ?? 'this computer';
24
+ await updateEntry(issuer, () => undefined);
25
+ try {
26
+ await revokeCredential(issuer, entry.credential);
27
+ }
28
+ catch (error) {
29
+ if (error instanceof NetworkError) {
30
+ console.error(`Removed the login from this computer. Couldn't reach ${issuer} to revoke it; revoke it at ${issuer}/account/developer`);
31
+ return 1;
32
+ }
33
+ if (!(error instanceof DirectoryError && error.status === 401))
34
+ throw error;
35
+ }
36
+ console.log(`Logged out. The login for ${name} is revoked.`);
37
+ return 0;
38
+ }
39
+ catch (error) {
40
+ return reportFailure(error);
41
+ }
42
+ }
@@ -0,0 +1,2 @@
1
+ /** The `crouter-sdk register` command. Returns the process exit code. */
2
+ export declare function runRegister(argv: string[]): Promise<number>;
@@ -0,0 +1,111 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { basename, resolve } from 'node:path';
3
+ import { usageOf } from './cli-usage.js';
4
+ import { callLeaf } from './directory-client.js';
5
+ import { envValueOf, keyFileOf, updateEnvText } from './env-command.js';
6
+ import { DEFAULT_KEY_FILE, ENV_PATH, LOGIN_COMMAND, openSession, parseFlags, publicJwkFromFile, readProjectEnv, reportFailure, resolveIssuer } from './developer-common.js';
7
+ import { DEFAULT_ISSUER } from './env-command.js';
8
+ const USAGE = usageOf('register');
9
+ const DEFAULT_LOGO = 'https://sdk.docs.northlight.ai/app-logo.png';
10
+ const BASE_SCOPES = ['crtr:llm', 'crtr:act'];
11
+ const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
12
+ async function defaultName() {
13
+ try {
14
+ const name = JSON.parse(await readFile('package.json', 'utf8')).name;
15
+ if (typeof name === 'string' && name !== '')
16
+ return name;
17
+ }
18
+ catch { /* no package.json: fall through to the directory name */ }
19
+ return basename(resolve('.'));
20
+ }
21
+ /** The `crouter-sdk register` command. Returns the process exit code. */
22
+ export async function runRegister(argv) {
23
+ let flags;
24
+ try {
25
+ flags = parseFlags(argv.slice(1), { value: ['--name', '--logo', '--justification', '--issuer'], list: ['--scope', '--redirect-uri'] }, USAGE);
26
+ if (flags.rest.length)
27
+ throw new Error(USAGE);
28
+ }
29
+ catch (error) {
30
+ console.error(error.message);
31
+ return 1;
32
+ }
33
+ const envText = await readProjectEnv();
34
+ if (envText === undefined) {
35
+ console.error(`No ${ENV_PATH} here. Run this in a project made by \`npx @north-light/sdk init\`.`);
36
+ return 1;
37
+ }
38
+ const existingId = envValueOf(envText, 'CROUTER_CLIENT_ID');
39
+ if (existingId) {
40
+ console.error(`This project is already registered as ${existingId}. Change it with \`npx @north-light/sdk apps …\`.`);
41
+ return 1;
42
+ }
43
+ const origin = envValueOf(envText, 'APP_ORIGIN');
44
+ if (!origin) {
45
+ console.error(`${ENV_PATH} has no APP_ORIGIN. Set it to where the app is served, for example http://localhost:3000.`);
46
+ return 1;
47
+ }
48
+ const keyFile = keyFileOf(envText) ?? DEFAULT_KEY_FILE;
49
+ let key;
50
+ try {
51
+ key = await publicJwkFromFile(keyFile);
52
+ }
53
+ catch (error) {
54
+ console.error(error.message);
55
+ return 1;
56
+ }
57
+ const issuer = resolveIssuer(flags.values.get('--issuer'), envText);
58
+ const name = flags.values.get('--name') ?? await defaultName();
59
+ const logo = flags.values.get('--logo') ?? DEFAULT_LOGO;
60
+ const justification = flags.values.get('--justification') ?? `${name} is in development.`;
61
+ const redirectUris = flags.lists.get('--redirect-uri') ?? [`${origin.replace(/\/+$/, '')}/auth/callback`];
62
+ const scopes = flags.lists.get('--scope') ?? [];
63
+ try {
64
+ const session = await openSession(issuer, `Not logged in. Run \`${LOGIN_COMMAND}\` first, or register in the console: ${issuer}/console/new`);
65
+ if (!session)
66
+ return 1;
67
+ const token = session.token.access_token;
68
+ const app = await callLeaf(issuer, token, 'register', {
69
+ name, origin, logo, justification, 'redirect-uri': redirectUris, jwk: key.json,
70
+ });
71
+ const clientId = String(app.client_id ?? '');
72
+ if (!clientId) {
73
+ console.error('The directory registered the app but did not answer its client ID. Find it with `npx @north-light/sdk apps mine`.');
74
+ return 1;
75
+ }
76
+ // Written before the scopes call, so a failure there leaves the env file complete.
77
+ await writeFile(ENV_PATH, updateEnvText(envText, { CROUTER_ISSUER: issuer, CROUTER_CLIENT_ID: clientId, APP_ORIGIN: origin }));
78
+ const registered = strings(app.requested_scopes) ?? BASE_SCOPES;
79
+ const summary = (asks) => [
80
+ `Registered ${name} (client ID ${clientId}). It is in testing: only you and your testers can sign in.`,
81
+ ` Origin: ${origin}`,
82
+ ` Redirect URI: ${strings(app.redirect_uris)?.join(' ') ?? redirectUris.join(' ')}`,
83
+ ` Public key: ${key.kid} (from ${keyFile})`,
84
+ ` Asks for: ${asks.join(' ')}`,
85
+ `Wrote CROUTER_CLIENT_ID to ${ENV_PATH}.`,
86
+ ];
87
+ let asks = registered;
88
+ if (scopes.length > 0) {
89
+ try {
90
+ const updated = await callLeaf(issuer, token, 'set-scopes', { 'app-id': clientId, add: scopes });
91
+ asks = strings(updated.requested_scopes) ?? [...registered, ...scopes.filter((scope) => !registered.includes(scope))];
92
+ }
93
+ catch (error) {
94
+ console.log(summary(registered).join('\n'));
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ const issuerFlag = issuer === DEFAULT_ISSUER ? '' : ` --issuer ${issuer}`;
97
+ console.error(`Couldn't add the scopes: ${message}. Run: npx @north-light/sdk apps set-scopes ${scopes.map((scope) => `--add ${scope}`).join(' ')}${issuerFlag}`);
98
+ return 1;
99
+ }
100
+ }
101
+ console.log([
102
+ ...summary(asks),
103
+ `Console: ${issuer}/console/apps/${clientId}`,
104
+ `Next: npm i && npm start, then open ${origin.replace(/\/+$/, '')}/auth/login`,
105
+ ].join('\n'));
106
+ return 0;
107
+ }
108
+ catch (error) {
109
+ return reportFailure(error);
110
+ }
111
+ }
@@ -2,7 +2,7 @@
2
2
  name: northlight
3
3
  description: "Use when building, debugging or shipping an app that signs people in with Northlight, starts runs on their Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk."
4
4
  metadata:
5
- sdk-version: "0.3.479"
5
+ sdk-version: "0.3.481"
6
6
  ---
7
7
 
8
8
  # Building a Northlight app
@@ -11,37 +11,29 @@ An app on Northlight signs people in with their Northlight account, then starts
11
11
 
12
12
  ## First: check this skill matches the SDK
13
13
 
14
- This skill is for SDK version `0.3.479`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. That command needs no account and no crtr. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
14
+ This skill is for SDK version `0.3.481`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. That command needs no account and no crtr. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
15
15
 
16
16
  ## Where things run
17
17
 
18
18
  - **Your server** runs wherever you choose (your laptop while developing). It holds the app's private signing key and each person's stored connection.
19
- - **account.northlight.ai** is the directory. People sign up and consent there. You register the app in its web console, `https://account.northlight.ai/console`.
19
+ - **account.northlight.ai** is the directory. People sign up and consent there. You register the app with `npx @north-light/sdk register`, or in its web console, `https://account.northlight.ai/console`.
20
20
  - **The person's runtime** runs their agents. Your server reaches it with `oauth.client(userId)` and calls `runs.*` and `providers.*` on it.
21
21
 
22
- An app signs requests with a key pair, not a client secret. The private key stays in `local/app-key.json`; the console holds only the public key.
22
+ An app signs requests with a key pair, not a client secret. The private key stays in `local/app-key.json`; the directory holds only the public key.
23
23
 
24
24
  ## The journey
25
25
 
26
- You do the command-line steps. The person does the console steps in a browser: ask them, name the exact field labels, and wait for the values you need back (the client ID).
26
+ You run the commands. The person does two things in a browser: signs up, and chooses Allow (on a login page, then on the app's consent page). Everything else is yours.
27
27
 
28
28
  1. **Account (person).** They sign up at `https://account.northlight.ai/signup`. Signup texts a code over iMessage, so the phone must have iMessage.
29
- 2. **Scaffold (you).** In a new folder run `npx @north-light/sdk@latest init my-app`. In an existing Node project run `npx @north-light/sdk init` instead: it adds the SDK pinned to an exact version, a key and this skill, and overwrites nothing. It prints the public JWK on stdout. `local/app.env` gets the issuer, the origin `http://localhost:3000`, and an empty `CROUTER_CLIENT_ID=`.
30
- 3. **Register (person).** At `https://account.northlight.ai/console/new`, under **Register an app**, they fill in:
31
- - **Name**, shown on the consent page.
32
- - **Origin URL**: `http://localhost:3000` for development.
33
- - **Logo URL**: a square image.
34
- - **Why your app needs access**: one sentence, quoted to people on the consent page.
35
- - **Redirect URIs**: `http://localhost:3000/auth/callback`. Sign-in returns only to a URI listed here, matched exactly, so `localhost` and `127.0.0.1` differ and the port counts.
36
- - **Public key**: the JWK that `init` printed. Never ask for, accept or paste the private key.
37
-
38
- The console then shows the **client ID**. Ask for it.
39
- 4. **Say what the app asks for (person).** On the app's **What it asks for** tab, **Add permissions**, then add every provider scope the code requests (the quickstart needs `sms:send`: choose Texting, tick it, choose **Done**, then choose **Save**; **Done** alone stores nothing). A new app starts with `crtr:llm` and `crtr:act`. Without a scope listed here the consent page does not offer it and the provider call is refused ([Scopes and grants](https://sdk.docs.northlight.ai/docs/concepts/scopes-and-grants.md)).
40
- 5. **Write the env file (you).** `npx @north-light/sdk env --client-id <client id>`. It sets `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` in `local/app.env` and keeps every other line.
41
- 6. **Run it (you).** `npm i`, then `node --env-file=local/app.env --import tsx server.ts`. Ask the person to open `http://localhost:3000/auth/login`, sign in, and choose Allow. The page then starts a run and sends them a text.
42
- 7. **Testing mode.** A new app is in testing: only its owner and up to 100 testers can sign in. Testers are added by email or phone on the console's **Testers** tab. Anyone else's callback gets `error=access_denied` with `error_description=app_in_testing` ([Testing mode](https://sdk.docs.northlight.ai/docs/concepts/testing-mode.md)). Going public needs verification: email support@northlight.ai.
43
-
44
- Check each step worked before the next: a `local/app-key.json` file exists, a client ID is in `local/app.env`, the server prints `Listening on http://localhost:3000`, `/auth/login` reaches the Northlight consent page, and the callback lands back on your server. If a step fails, read the error and the page it names before changing code.
29
+ 2. **Scaffold (you).** In a new folder run `npx @north-light/sdk@latest init my-app`. In an existing Node project run `npx @north-light/sdk init` instead: it adds the SDK pinned to an exact version, a key and this skill, and overwrites nothing. A new app gets `local/app.env` with the issuer, the origin `http://localhost:3000` and an empty `CROUTER_CLIENT_ID=`.
30
+ 3. **Log in (you run it, the person approves).** Run `npx @north-light/sdk login`. It prints a link and a code. Show the person the link and the code exactly as printed and ask them to open the link, check the page shows the same code, and choose **Allow**. If your command times out first, run `login` again: it resumes the same code (do not pass `--new`). `npx @north-light/sdk whoami` checks the login. If `login` cannot reach `account.northlight.ai`, use step 7.
31
+ 4. **Register (you).** For the quickstart app run `npx @north-light/sdk register --scope sms:send`. `sms:send` is the scope `server.ts` requests. It registers the app with the public key from `local/app-key.json`, adds the scope and writes `CROUTER_CLIENT_ID` to `local/app.env`. For any other provider scope: register first, find the string with `npx @north-light/sdk apps asks` (it needs the registered app), then add it with `npx @north-light/sdk apps set-scopes --add <scope>`. Never invent a scope string ([Scopes and grants](https://sdk.docs.northlight.ai/docs/concepts/scopes-and-grants.md)).
32
+ 5. **Run it (you, then the person).** `npm i`, then `npm start`. Ask the person to open `http://localhost:3000/auth/login`, sign in and choose **Allow**. The page then starts a run and sends them a text.
33
+ 6. **Testing mode.** A new app is in testing: only its owner and up to 100 testers can sign in. Add one with `npx @north-light/sdk apps testers add --email <email>`. Anyone else's callback gets `error=access_denied` with `error_description=app_in_testing` ([Testing mode](https://sdk.docs.northlight.ai/docs/concepts/testing-mode.md)). Going public needs verification: email support@northlight.ai.
34
+ 7. **If login is not possible** (no network to `account.northlight.ai`, or the person prefers the browser): they register at `https://account.northlight.ai/console/new`. Ask them to fill in **Name**, **Origin URL** (`http://localhost:3000`), **Logo URL** (a square image), **Why your app needs access** (one sentence) and **Redirect URIs** (`http://localhost:3000/auth/callback`, matched exactly), and to paste the public JWK that `init` printed. Never ask for or accept the private key. On the app's **What it asks for** tab they choose **Add permissions**, add `sms:send` and choose **Save** (**Done** alone stores nothing). Ask for the client ID the console shows, then run `npx @north-light/sdk env --client-id <client id>`.
35
+
36
+ Check each step worked before the next: a `local/app-key.json` file exists, `whoami` names the person, a client ID is in `local/app.env`, the server prints `Listening on http://localhost:3000`, `/auth/login` reaches the Northlight consent page, and the callback lands back on your server. If a step fails, read the error and the page it names before changing code.
45
37
 
46
38
  ## Code patterns
47
39
 
@@ -153,8 +145,10 @@ export async function textMe(runtime: ConnectedCrouter, body: string) {
153
145
  Details behind each rule: [Errors](https://sdk.docs.northlight.ai/docs/errors.md), [Typed results](https://sdk.docs.northlight.ai/docs/guides/typed-results.md), [Connections](https://sdk.docs.northlight.ai/docs/concepts/connections.md).
154
146
 
155
147
  - **Pin the exact SDK version** (`npm i --save-exact`). A later release can change a type or method, and `^` picks it up on the next `npm i`.
156
- - **Take scope strings from the console's Add permissions list or from `providers.tools`.** Never invent one. Tool names and arguments also come from `providers.tools('<provider>')`.
157
- - **Never print, paste, log or commit `local/app-key.json`.** `local/` is in `.gitignore`. If it leaks, add a new key in the console, switch to it, and retire the old one.
148
+ - **Take scope strings from `npx @north-light/sdk apps asks`, the console's Add permissions list or `providers.tools`.** Never invent one. Tool names and arguments also come from `providers.tools('<provider>')`.
149
+ - **Never print, paste, log or commit `local/app-key.json`, `~/.config/northlight/credentials.json` or any `nld_` value.** `local/` is in `.gitignore`. If the app key leaks, add a new key in the console, switch to it, and retire the old one.
150
+ - **Do not run `logout` unless the person asks.** It revokes the login and the next session must ask them to approve again.
151
+ - **Deleting an app, listing it in the marketplace, its spend cap and sharing are the person's, in the console** (`https://account.northlight.ai/console`). Name the page; do not try the CLI, which refuses. On a verified app, signing keys, the JWKS URL, redirect URIs and the run notice URL change there too ([Manage your app](https://sdk.docs.northlight.ai/docs/guides/manage-your-app.md)).
158
152
  - **Use `oauth.client(userId)` for a person's runtime. Never `new Crouter()`.** `Crouter` is the client for a runtime you run yourself, not for apps.
159
153
  - **Use a persistent `ConnectionStore` in production.** `MemoryConnectionStore` loses every sign-in on restart and does not work across processes. `@north-light/sdk/stores/postgres` is the production store.
160
154
  - **Use `runs.parse` with a schema for data. Never parse `runs.trace`.** The trace is what the model said; `output_parsed` is a value checked against your schema.
@@ -172,6 +166,11 @@ Details behind each rule: [Errors](https://sdk.docs.northlight.ai/docs/errors.md
172
166
  | `skill [--agent claude|agents|all] [--global] [--print]` | Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout. | No |
173
167
  | `keygen [--out <path>] [--app <client id>] [--yes]` | Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register. | Only with `--app` |
174
168
  | `env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]`<br>`env --app <client id> [--out <path>]` | Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr. | Only with `--app` |
169
+ | `login [--no-browser] [--new] [--issuer <url>]` | Log this computer in to your Northlight account: prints a link and a code, you approve once in the browser, and a revocable login is saved to `~/.config/northlight/credentials.json`. Run it again if it times out: it resumes the same code. Calls only the Northlight directory. | No |
170
+ | `logout [--issuer <url>]` | Revoke this computer's login on the server and delete it locally. | No |
171
+ | `whoami [--issuer <url>]` | Show the account this computer is logged in as and when the login expires. Exits 1 when not logged in. | No |
172
+ | `register [--name <text>] [--scope <scope>]... [--logo <url>] [--justification <text>] [--redirect-uri <uri>]... [--issuer <url>]` | Run in a project made by `init`, after `login`: registers the app with the public key from `local/app-key.json`, adds each `--scope` beyond `crtr:llm crtr:act`, and writes `CROUTER_CLIENT_ID` and `CROUTER_ISSUER` to `local/app.env`. | No |
173
+ | `apps <leaf> [--app <client id>] [--issuer <url>] [leaf flags] [--json]` | Call one app-management leaf with the login and print its answer as JSON. Leaves: `mine`, `get`, `update`, `set-redirect-uris`, `asks`, `set-scopes`, `set-requires`, `set-run-notice-url`, `keys list|add|retire|delete|set-jwks-uri|remove-jwks-uri`, `testers list|add|remove`. `--app` defaults to `CROUTER_CLIENT_ID` in `local/app.env`; `keys add` takes `--key-file <path>` and sends only the public half. Deleting an app, its marketplace listing, spend cap, sharing and provider role are console-only. | No |
175
174
 
176
175
  ## Pages
177
176
 
package/dist/version.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
2
- export declare const SDK_VERSION = "0.3.479";
2
+ export declare const SDK_VERSION = "0.3.481";
3
3
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
4
4
  * contract level existed (which only knows this header) refuses this SDK, while every later runtime accepts it. */
5
5
  export declare const LEGACY_FLOOR = "0.3.423";
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SDK_VERSION is written by scripts/release-version.mjs on every release; do not edit by hand.
2
2
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
3
- export const SDK_VERSION = '0.3.479';
3
+ export const SDK_VERSION = '0.3.481';
4
4
  // LEGACY_FLOOR is written once by `scripts/contract.mjs release`, at the first release that carries the contract level
5
5
  // (the `0.0.0` placeholder is replaced there and never again); do not edit by hand.
6
6
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
@@ -0,0 +1 @@
1
+ export declare function runWhoami(argv: string[]): Promise<number>;
@@ -0,0 +1,30 @@
1
+ import { updateEntry } from './credentials.js';
2
+ import { usageOf } from './cli-usage.js';
3
+ import { NOT_LOGGED_IN, openSession, parseFlags, reportFailure, resolveIssuer } from './developer-common.js';
4
+ export async function runWhoami(argv) {
5
+ let flags;
6
+ try {
7
+ flags = parseFlags(argv.slice(1), { value: ['--issuer'] }, usageOf('whoami'));
8
+ if (flags.rest.length)
9
+ throw new Error(usageOf('whoami'));
10
+ }
11
+ catch (error) {
12
+ console.error(error.message);
13
+ return 1;
14
+ }
15
+ const issuer = resolveIssuer(flags.values.get('--issuer'));
16
+ try {
17
+ const session = await openSession(issuer, NOT_LOGGED_IN, async () => { await updateEntry(issuer, () => undefined); });
18
+ if (!session)
19
+ return 1;
20
+ const { token, entry } = session;
21
+ console.log([
22
+ `${token.account.display} (${token.account.id}) on ${issuer}`,
23
+ `Login ${entry.name ?? token.credential_id}, expires ${token.expires_at.slice(0, 10)} or after 14 days unused.`,
24
+ ].join('\n'));
25
+ return 0;
26
+ }
27
+ catch (error) {
28
+ return reportFailure(error);
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/sdk",
3
- "version": "0.3.479",
3
+ "version": "0.3.481",
4
4
  "crouterContract": "1.7",
5
5
  "description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
6
6
  "type": "module",
@@ -44,8 +44,8 @@
44
44
  "prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'next') { console.error('@north-light/sdk publishes only with --tag next; scripts/promote-sdk.mjs is the only mover of latest.'); process.exit(1); }\""
45
45
  },
46
46
  "dependencies": {
47
- "@north-light/api": "0.3.479",
48
- "@north-light/identity": "0.3.479",
47
+ "@north-light/api": "0.3.481",
48
+ "@north-light/identity": "0.3.481",
49
49
  "jose": "^6.2.1"
50
50
  },
51
51
  "peerDependencies": {