@notis_ai/cli 0.2.12 → 0.2.14
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 +56 -3
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +2 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +132 -1
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +11 -7
- package/skills/notis-apps/cli.md +8 -3
- package/skills/notis-cli/SKILL.md +2 -0
- package/src/cli.js +158 -0
- package/src/command-specs/apps.js +238 -50
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/index.js +3 -0
- package/src/command-specs/meta.js +53 -0
- package/src/command-specs/tools.js +6 -0
- package/src/runtime/app-dev-server.js +17 -8
- package/src/runtime/app-platform.js +218 -6
- package/src/runtime/auth-recovery.js +13 -3
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/oauth.js +36 -4
- package/src/runtime/profiles.js +17 -1
- package/src/runtime/transport.js +19 -2
- package/template/.harness/index.html.tmpl +116 -47
- package/template/packages/sdk/src/config.ts +52 -0
- package/template/packages/sdk/src/documents.ts +21 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/template/packages/sdk/src/index.ts +17 -0
- package/template/packages/sdk/src/runtime.ts +132 -1
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local git inspection for `notis handover`.
|
|
3
|
+
*
|
|
4
|
+
* The CLI has no other reason to know about git, so this stays deliberately
|
|
5
|
+
* small: read where we are, push the branch, and report what the remote is.
|
|
6
|
+
* Everything else about the repository is the cloud workspace's problem.
|
|
7
|
+
*
|
|
8
|
+
* Every call is `spawnSync` on the real `git` binary rather than a library.
|
|
9
|
+
* The user's credentials, hooks, SSH agent and includeIf config are what make a
|
|
10
|
+
* push succeed on their machine, and only their own git honors all of them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { basename, relative, resolve, sep } from 'node:path';
|
|
16
|
+
import { CliError, EXIT_CODES } from './errors.js';
|
|
17
|
+
|
|
18
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
19
|
+
const MAX_SECRET_SCAN_BYTES = 1_000_000;
|
|
20
|
+
const SAFE_ENV_TEMPLATES = /^\.env\.(?:example|sample|template)$/i;
|
|
21
|
+
const SENSITIVE_PATH = /^(?:\.env(?:\..+)?|\.envrc|\.git-credentials|\.npmrc|\.pypirc|\.netrc|credentials(?:\..+)?|secrets?(?:\..+)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|.*\.(?:pem|key|p12|pfx|jks))$/i;
|
|
22
|
+
const SENSITIVE_CONTENT = [
|
|
23
|
+
/-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
24
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i,
|
|
25
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
26
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
|
|
27
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function runGit(
|
|
31
|
+
args,
|
|
32
|
+
{ cwd = process.cwd(), timeoutMs = GIT_TIMEOUT_MS, trimOutput = true } = {},
|
|
33
|
+
) {
|
|
34
|
+
const result = spawnSync('git', args, {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
timeout: timeoutMs,
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
// A push that stops to ask for a password would hang a non-interactive
|
|
41
|
+
// agent run forever. Fail fast instead and let the hint explain.
|
|
42
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (result.error && result.error.code === 'ENOENT') {
|
|
46
|
+
throw new CliError({
|
|
47
|
+
code: 'git_not_found',
|
|
48
|
+
message: 'git is not installed or not on PATH.',
|
|
49
|
+
exitCode: EXIT_CODES.usage,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
exitCode: result.status ?? 1,
|
|
54
|
+
stdout: trimOutput ? (result.stdout || '').trim() : (result.stdout || ''),
|
|
55
|
+
stderr: trimOutput ? (result.stderr || '').trim() : (result.stderr || ''),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function gitOrNull(args, options) {
|
|
60
|
+
const result = runGit(args, options);
|
|
61
|
+
return result.exitCode === 0 ? result.stdout : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nulSeparatedGitPaths(args, repository) {
|
|
65
|
+
const result = runGit([...args, '-z'], { cwd: repository.toplevel, trimOutput: false });
|
|
66
|
+
if (result.exitCode !== 0 || !result.stdout) return [];
|
|
67
|
+
return result.stdout.split('\0').filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Exact files `git add -A` would include, expanding untracked directories. */
|
|
71
|
+
function filesToAutoCommit(repository) {
|
|
72
|
+
return [...new Set([
|
|
73
|
+
...nulSeparatedGitPaths(['diff', '--name-only'], repository),
|
|
74
|
+
...nulSeparatedGitPaths(['diff', '--cached', '--name-only'], repository),
|
|
75
|
+
...nulSeparatedGitPaths(['ls-files', '--others', '--exclude-standard'], repository),
|
|
76
|
+
])];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Refuse files that are unsafe to publish automatically without user review. */
|
|
80
|
+
export function sensitiveAutoCommitFiles(repository) {
|
|
81
|
+
const root = resolve(repository.toplevel);
|
|
82
|
+
const sensitive = [];
|
|
83
|
+
for (const path of filesToAutoCommit(repository)) {
|
|
84
|
+
const absolute = resolve(root, path);
|
|
85
|
+
const insideRoot = relative(root, absolute);
|
|
86
|
+
if (insideRoot === '..' || insideRoot.startsWith(`..${sep}`) || insideRoot === '') continue;
|
|
87
|
+
const name = basename(path);
|
|
88
|
+
if (SENSITIVE_PATH.test(name) && !SAFE_ENV_TEMPLATES.test(name)) {
|
|
89
|
+
sensitive.push(path);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const stat = lstatSync(absolute);
|
|
94
|
+
if (!stat.isFile() || stat.size > MAX_SECRET_SCAN_BYTES) continue;
|
|
95
|
+
const content = readFileSync(absolute, 'utf8');
|
|
96
|
+
if (SENSITIVE_CONTENT.some((pattern) => pattern.test(content))) sensitive.push(path);
|
|
97
|
+
} catch {
|
|
98
|
+
// Deleted files and paths racing with an editor have no content to leak.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return sensitive;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse an origin URL into {owner, repo}. Handles the three forms git remotes
|
|
106
|
+
* actually take: scp-style ssh, ssh:// and https://.
|
|
107
|
+
*/
|
|
108
|
+
export function parseRemoteUrl(url) {
|
|
109
|
+
if (typeof url !== 'string' || !url) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const trimmed = url.trim().replace(/\.git$/, '');
|
|
113
|
+
const scp = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
|
114
|
+
const path = scp ? scp[2] : trimmed.replace(/^[a-z+]+:\/\/(?:[^@/]+@)?[^/]+\//i, '');
|
|
115
|
+
const host = scp ? scp[1] : (trimmed.match(/^[a-z+]+:\/\/(?:[^@/]+@)?([^/]+)/i) || [])[1];
|
|
116
|
+
const segments = path.split('/').filter(Boolean);
|
|
117
|
+
if (segments.length < 2) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
host: host || null,
|
|
122
|
+
owner: segments[segments.length - 2],
|
|
123
|
+
repo: segments[segments.length - 1],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Everything `handover start` needs to know about where it is running. */
|
|
128
|
+
export function inspectRepository(cwd = process.cwd()) {
|
|
129
|
+
const toplevel = gitOrNull(['rev-parse', '--show-toplevel'], { cwd });
|
|
130
|
+
if (!toplevel) {
|
|
131
|
+
throw new CliError({
|
|
132
|
+
code: 'not_a_git_repository',
|
|
133
|
+
message: 'Hand-over runs from inside a git repository.',
|
|
134
|
+
exitCode: EXIT_CODES.usage,
|
|
135
|
+
hints: [{ message: 'cd into the repository you want Notis to work on, then run the command again.' }],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const branch = gitOrNull(['branch', '--show-current'], { cwd: toplevel });
|
|
140
|
+
if (!branch) {
|
|
141
|
+
throw new CliError({
|
|
142
|
+
code: 'detached_head',
|
|
143
|
+
message: 'HEAD is detached, so there is no branch to hand over.',
|
|
144
|
+
exitCode: EXIT_CODES.usage,
|
|
145
|
+
hints: [{ message: 'Check out a branch first: git switch -c my-feature' }],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const remoteUrl = gitOrNull(['remote', 'get-url', 'origin'], { cwd: toplevel });
|
|
150
|
+
if (!remoteUrl) {
|
|
151
|
+
throw new CliError({
|
|
152
|
+
code: 'no_origin_remote',
|
|
153
|
+
message: 'This repository has no "origin" remote, so the cloud agent cannot fetch the branch.',
|
|
154
|
+
exitCode: EXIT_CODES.usage,
|
|
155
|
+
hints: [{ message: 'Add one: git remote add origin <url>' }],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const status = runGit(['status', '--porcelain'], { cwd: toplevel });
|
|
160
|
+
const dirtyFiles = status.stdout
|
|
161
|
+
? status.stdout.split('\n').map((line) => line.slice(3).trim()).filter(Boolean)
|
|
162
|
+
: [];
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
toplevel,
|
|
166
|
+
branch,
|
|
167
|
+
remoteUrl,
|
|
168
|
+
remote: parseRemoteUrl(remoteUrl),
|
|
169
|
+
dirtyFiles,
|
|
170
|
+
head: gitOrNull(['rev-parse', 'HEAD'], { cwd: toplevel }),
|
|
171
|
+
upstream: gitOrNull(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
|
|
172
|
+
cwd: toplevel,
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Commit everything in the working tree, including untracked files. */
|
|
178
|
+
export function commitWorkingTree(repository, message) {
|
|
179
|
+
const sensitiveFiles = sensitiveAutoCommitFiles(repository);
|
|
180
|
+
if (sensitiveFiles.length) {
|
|
181
|
+
throw new CliError({
|
|
182
|
+
code: 'sensitive_working_tree',
|
|
183
|
+
message: 'Refusing to publish files that may contain credentials or private keys.',
|
|
184
|
+
exitCode: EXIT_CODES.conflict,
|
|
185
|
+
details: { sensitive_files: sensitiveFiles.slice(0, 20) },
|
|
186
|
+
hints: [
|
|
187
|
+
{ message: 'Review, remove, or ignore these files before handing over.' },
|
|
188
|
+
{ message: 'To publish them deliberately, commit and push them yourself first.' },
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const add = runGit(['add', '-A'], { cwd: repository.toplevel });
|
|
193
|
+
if (add.exitCode !== 0) {
|
|
194
|
+
throw new CliError({
|
|
195
|
+
code: 'git_add_failed',
|
|
196
|
+
message: `Could not stage the working tree: ${add.stderr || add.stdout}`,
|
|
197
|
+
exitCode: EXIT_CODES.unexpected,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const commit = runGit(['commit', '-m', message], { cwd: repository.toplevel });
|
|
201
|
+
if (commit.exitCode !== 0) {
|
|
202
|
+
throw new CliError({
|
|
203
|
+
code: 'git_commit_failed',
|
|
204
|
+
message: `Could not commit the working tree: ${commit.stderr || commit.stdout}`,
|
|
205
|
+
exitCode: EXIT_CODES.unexpected,
|
|
206
|
+
hints: [{ message: 'Commit the changes yourself, then run the hand-over again.' }],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return gitOrNull(['rev-parse', 'HEAD'], { cwd: repository.toplevel });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Push the branch to origin. The cloud workspace fetches from origin, so an
|
|
214
|
+
* unpushed commit simply does not exist as far as the hand-over is concerned.
|
|
215
|
+
*/
|
|
216
|
+
export function pushBranch(repository, branch) {
|
|
217
|
+
const args = repository.upstream
|
|
218
|
+
? ['push', 'origin', branch]
|
|
219
|
+
: ['push', '--set-upstream', 'origin', branch];
|
|
220
|
+
const result = runGit(args, { cwd: repository.toplevel });
|
|
221
|
+
if (result.exitCode !== 0) {
|
|
222
|
+
throw new CliError({
|
|
223
|
+
code: 'git_push_failed',
|
|
224
|
+
message: `Could not push ${branch} to origin: ${result.stderr || result.stdout}`,
|
|
225
|
+
exitCode: EXIT_CODES.conflict,
|
|
226
|
+
hints: [
|
|
227
|
+
{ message: 'The cloud agent works from origin, so the branch has to be pushed first.' },
|
|
228
|
+
{ message: 'If the remote moved ahead, reconcile locally (git pull --rebase) and retry.' },
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|
package/src/runtime/oauth.js
CHANGED
|
@@ -19,6 +19,11 @@ import { createInterface } from 'node:readline/promises';
|
|
|
19
19
|
|
|
20
20
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
21
21
|
import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
|
|
22
|
+
import {
|
|
23
|
+
channelFromProfile,
|
|
24
|
+
cliCommandForChannel,
|
|
25
|
+
isReleaseChannel,
|
|
26
|
+
} from './channel.js';
|
|
22
27
|
import {
|
|
23
28
|
credentialIsExpired,
|
|
24
29
|
ensureProfile,
|
|
@@ -137,6 +142,12 @@ export async function discoverCliOAuth(apiBase, fetchImpl = fetch) {
|
|
|
137
142
|
resource: protectedResource.resource,
|
|
138
143
|
clientId: protectedResource.notis_cli_client_id || 'notis_cli',
|
|
139
144
|
copyPasteRedirectUri: protectedResource.notis_cli_copy_paste_redirect_uri,
|
|
145
|
+
// A deployment that predates channel advertising, or a local one with no
|
|
146
|
+
// published build, leaves this null and the profile keeps resolving its
|
|
147
|
+
// channel from the endpoint it authorized against.
|
|
148
|
+
channel: isReleaseChannel(protectedResource.notis_cli_channel)
|
|
149
|
+
? protectedResource.notis_cli_channel
|
|
150
|
+
: null,
|
|
140
151
|
authorizationEndpoint: authorizationServer.authorization_endpoint,
|
|
141
152
|
tokenEndpoint: authorizationServer.token_endpoint,
|
|
142
153
|
revocationEndpoint: authorizationServer.revocation_endpoint,
|
|
@@ -718,6 +729,13 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
|
|
|
718
729
|
// only endpoint the resulting token is accepted by.
|
|
719
730
|
api_base: oauthApiBase || profile.api_base,
|
|
720
731
|
beta: beta ?? profile.beta,
|
|
732
|
+
// The deployment that just authorized this profile also names the
|
|
733
|
+
// published build that belongs to it. Pinning it here is what lets the
|
|
734
|
+
// next run correct itself without the user knowing a channel exists.
|
|
735
|
+
channel: isReleaseChannel(metadata.channel)
|
|
736
|
+
? metadata.channel
|
|
737
|
+
: channelFromProfile({ ...profile, beta: beta ?? profile.beta, api_base: oauthApiBase })
|
|
738
|
+
?? profile.channel,
|
|
721
739
|
oauth_api_base: oauthApiBase || profile.oauth_api_base,
|
|
722
740
|
oauth_resource: metadata.resource,
|
|
723
741
|
oauth_access_token: tokenResponse.access_token,
|
|
@@ -784,14 +802,20 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
|
|
|
784
802
|
}
|
|
785
803
|
}
|
|
786
804
|
|
|
787
|
-
function redeemCommand(profileName) {
|
|
805
|
+
function redeemCommand(profileName, channel) {
|
|
788
806
|
return [
|
|
789
|
-
|
|
807
|
+
cliCommandForChannel(channel),
|
|
790
808
|
`--profile ${quoteShellArgument(profileName || 'default')}`,
|
|
791
809
|
'login --code <code>',
|
|
792
810
|
].join(' ');
|
|
793
811
|
}
|
|
794
812
|
|
|
813
|
+
function authorizationChannel(metadata, runtime, pending = null) {
|
|
814
|
+
return metadata.channel
|
|
815
|
+
|| pending?.channel
|
|
816
|
+
|| channelFromProfile({ api_base: pending?.api_base || runtime.apiBase });
|
|
817
|
+
}
|
|
818
|
+
|
|
795
819
|
function updateRuntimeFromOAuthProfile(runtime, profile) {
|
|
796
820
|
const oauthApiBase = getOAuthApiBase(profile);
|
|
797
821
|
runtime.jwt = profile.oauth_access_token;
|
|
@@ -875,6 +899,7 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
|
|
|
875
899
|
resource: pending.resource,
|
|
876
900
|
clientId: pending.client_id,
|
|
877
901
|
tokenEndpoint: pending.token_endpoint,
|
|
902
|
+
channel: pending.channel,
|
|
878
903
|
};
|
|
879
904
|
if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
|
|
880
905
|
throw oauthError(
|
|
@@ -961,7 +986,10 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
961
986
|
scopes: pendingScopes,
|
|
962
987
|
}),
|
|
963
988
|
expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
|
|
964
|
-
redeem_command: redeemCommand(
|
|
989
|
+
redeem_command: redeemCommand(
|
|
990
|
+
runtime.profileName,
|
|
991
|
+
authorizationChannel(metadata, runtime, pending),
|
|
992
|
+
),
|
|
965
993
|
},
|
|
966
994
|
};
|
|
967
995
|
}
|
|
@@ -1010,6 +1038,7 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
1010
1038
|
client_id: metadata.clientId,
|
|
1011
1039
|
token_endpoint: metadata.tokenEndpoint,
|
|
1012
1040
|
authorization_endpoint: metadata.authorizationEndpoint,
|
|
1041
|
+
channel: authorizationChannel(metadata, runtime),
|
|
1013
1042
|
scopes,
|
|
1014
1043
|
expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
|
|
1015
1044
|
});
|
|
@@ -1021,7 +1050,10 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
1021
1050
|
agentAuthorization: {
|
|
1022
1051
|
authorize_url: authorizeUrl,
|
|
1023
1052
|
expires_in: PENDING_LOGIN_TTL_SECONDS,
|
|
1024
|
-
redeem_command: redeemCommand(
|
|
1053
|
+
redeem_command: redeemCommand(
|
|
1054
|
+
runtime.profileName,
|
|
1055
|
+
authorizationChannel(metadata, runtime),
|
|
1056
|
+
),
|
|
1025
1057
|
},
|
|
1026
1058
|
};
|
|
1027
1059
|
}
|
package/src/runtime/profiles.js
CHANGED
|
@@ -12,6 +12,7 @@ import { homedir } from 'node:os';
|
|
|
12
12
|
import { dirname, join, parse, resolve } from 'node:path';
|
|
13
13
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
14
14
|
import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
|
|
15
|
+
import { channelFromProfile, cliCommandForChannel, isReleaseChannel } from './channel.js';
|
|
15
16
|
|
|
16
17
|
export const CONFIG_DIR = join(homedir(), '.notis');
|
|
17
18
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
@@ -95,6 +96,10 @@ function normalizeProfile(rawProfile = {}) {
|
|
|
95
96
|
return {
|
|
96
97
|
api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
|
|
97
98
|
beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
|
|
99
|
+
// Which published CLI build this profile runs, as the deployment reported
|
|
100
|
+
// it at login. Unknown values are dropped rather than trusted: this key
|
|
101
|
+
// decides which code executes on the next run.
|
|
102
|
+
channel: isReleaseChannel(raw.channel) ? raw.channel : undefined,
|
|
98
103
|
label: typeof raw.label === 'string' ? raw.label : undefined,
|
|
99
104
|
dev_access_token:
|
|
100
105
|
typeof raw.dev_access_token === 'string' ? raw.dev_access_token : undefined,
|
|
@@ -877,7 +882,7 @@ export function resolveRuntimeProfile(
|
|
|
877
882
|
exitCode: EXIT_CODES.auth,
|
|
878
883
|
hints: [{
|
|
879
884
|
command: [
|
|
880
|
-
|
|
885
|
+
cliCommandForChannel(channelFromProfile({ api_base: normalizedRequestedApiBase })),
|
|
881
886
|
`--profile ${quoteShellArgument(profileName)}`,
|
|
882
887
|
`--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
|
|
883
888
|
'login',
|
|
@@ -929,6 +934,17 @@ export function resolveRuntimeProfile(
|
|
|
929
934
|
profileName,
|
|
930
935
|
profileSource,
|
|
931
936
|
profileLabel: profile.label,
|
|
937
|
+
// Which published build serves this profile. Carried on the runtime so
|
|
938
|
+
// every recovery hint prints the command that will actually run.
|
|
939
|
+
channel: devRuntime
|
|
940
|
+
? null
|
|
941
|
+
: channelFromProfile(
|
|
942
|
+
(globalOptions.apiBase || process.env.NOTIS_API_BASE)
|
|
943
|
+
// An explicit route owns this invocation even when the stored
|
|
944
|
+
// profile is pinned to the opposite release channel.
|
|
945
|
+
? { api_base: apiBase }
|
|
946
|
+
: { ...profile, api_base: apiBase },
|
|
947
|
+
),
|
|
932
948
|
apiBase,
|
|
933
949
|
requestedApiBase: normalizedRequestedApiBase,
|
|
934
950
|
jwt,
|
package/src/runtime/transport.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
5
|
+
import { delegatedContextReason } from './delegated-context.js';
|
|
5
6
|
import {
|
|
6
7
|
credentialIsExpired,
|
|
7
8
|
getJwtExpiration,
|
|
@@ -327,9 +328,11 @@ export async function httpRequest({
|
|
|
327
328
|
});
|
|
328
329
|
|
|
329
330
|
let payload = null;
|
|
331
|
+
let payloadReadError = null;
|
|
330
332
|
try {
|
|
331
333
|
payload = await response.json();
|
|
332
|
-
} catch {
|
|
334
|
+
} catch (error) {
|
|
335
|
+
payloadReadError = error;
|
|
333
336
|
payload = null;
|
|
334
337
|
}
|
|
335
338
|
|
|
@@ -350,9 +353,11 @@ export async function httpRequest({
|
|
|
350
353
|
signal: controller.signal,
|
|
351
354
|
...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
|
|
352
355
|
});
|
|
356
|
+
payloadReadError = null;
|
|
353
357
|
try {
|
|
354
358
|
payload = await response.json();
|
|
355
|
-
} catch {
|
|
359
|
+
} catch (error) {
|
|
360
|
+
payloadReadError = error;
|
|
356
361
|
payload = null;
|
|
357
362
|
}
|
|
358
363
|
}
|
|
@@ -376,6 +381,14 @@ export async function httpRequest({
|
|
|
376
381
|
throw normalizeBackendError(response.status, payload, runtime);
|
|
377
382
|
}
|
|
378
383
|
|
|
384
|
+
// A successful status is not a successful tool call until its response
|
|
385
|
+
// body has been read. Mutations may already have committed before a socket
|
|
386
|
+
// reset truncates the JSON; surface that as an ambiguous network failure
|
|
387
|
+
// so callers never persist an undefined result or attempt a second write.
|
|
388
|
+
if (response.status !== 204 && payloadReadError) {
|
|
389
|
+
throw payloadReadError;
|
|
390
|
+
}
|
|
391
|
+
|
|
379
392
|
return {
|
|
380
393
|
requestId,
|
|
381
394
|
payload: payload || {},
|
|
@@ -428,6 +441,10 @@ export async function callTool({
|
|
|
428
441
|
cwd: process.cwd(),
|
|
429
442
|
agent_mode: runtime.agentMode,
|
|
430
443
|
cli_version: runtime.cliVersion,
|
|
444
|
+
// Reported so the server can refuse work-handing tools from a run Notis
|
|
445
|
+
// is itself driving. Advisory -- a determined agent can unset the markers
|
|
446
|
+
// -- so the server treats it as one layer, not the whole guard.
|
|
447
|
+
...(delegatedContextReason() ? { delegated_context: true } : {}),
|
|
431
448
|
...(runtime.debugEntitlementOverride
|
|
432
449
|
? { debug_entitlement_override: runtime.debugEntitlementOverride }
|
|
433
450
|
: {}),
|
|
@@ -164,8 +164,35 @@
|
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Records one runtime call and hands back the entry so the caller can
|
|
169
|
+
* stamp its outcome once the promise settles. Verify reads `ok` to tell a
|
|
170
|
+
* route that rendered from real data apart from one that only rendered its
|
|
171
|
+
* error states, so every runtime path must settle its own entry.
|
|
172
|
+
*/
|
|
167
173
|
function record(op, args) {
|
|
168
|
-
|
|
174
|
+
const entry = { op, args, ok: null, error: null, durationMs: null };
|
|
175
|
+
entry.startedAt = Date.now();
|
|
176
|
+
window.__harness.runtimeCalls.push(entry);
|
|
177
|
+
return entry;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function settle(entry, error) {
|
|
181
|
+
entry.durationMs = Date.now() - entry.startedAt;
|
|
182
|
+
delete entry.startedAt;
|
|
183
|
+
entry.ok = !error;
|
|
184
|
+
entry.error = error ? (error.message ? String(error.message) : String(error)) : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function tracked(entry, run) {
|
|
188
|
+
try {
|
|
189
|
+
const value = await run();
|
|
190
|
+
settle(entry, null);
|
|
191
|
+
return value;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
settle(entry, error);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
169
196
|
}
|
|
170
197
|
|
|
171
198
|
async function readJsonOrSnippet(response) {
|
|
@@ -214,29 +241,58 @@
|
|
|
214
241
|
app: descriptor.app,
|
|
215
242
|
route: descriptor.route,
|
|
216
243
|
context: descriptor.context || {},
|
|
217
|
-
navigate: (args) =>
|
|
244
|
+
navigate: (args) => {
|
|
245
|
+
settle(record('navigate', args), null);
|
|
246
|
+
},
|
|
247
|
+
// There is no manager chat here, so the handover is recorded and
|
|
248
|
+
// reported as drafted without any UI.
|
|
249
|
+
handover: async (payload) => {
|
|
250
|
+
settle(record('handover', payload), null);
|
|
251
|
+
return { status: 'drafted' };
|
|
252
|
+
},
|
|
218
253
|
registerTopBarSearch: () => {},
|
|
219
254
|
setTopBarSearchValue: () => {},
|
|
220
255
|
setTopBarSearchLoading: () => {},
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
256
|
+
// No change feed here, so `useDatabaseSubscription` reports live=false
|
|
257
|
+
// and the app keeps whatever manual refresh it offers.
|
|
258
|
+
subscribeDatabase: () => () => {},
|
|
259
|
+
// There is no cloud computer behind the harness. Answering "not
|
|
260
|
+
// available" rather than throwing keeps `useCloudComputer` on the same
|
|
261
|
+
// fallback branch a user without a cloud computer would see.
|
|
262
|
+
cloudComputerFacts: async () => tracked(
|
|
263
|
+
record('cloudComputerFacts', {}),
|
|
264
|
+
async () => ({
|
|
265
|
+
available: false,
|
|
266
|
+
reason: 'unsupported_host',
|
|
267
|
+
sandbox: null,
|
|
268
|
+
cli_auth: {
|
|
269
|
+
gh: { authenticated: null, account: null, checked_at: null, reason: 'unsupported_host' },
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
listTools: async () => tracked(
|
|
274
|
+
record('listTools', {}),
|
|
275
|
+
async () => descriptor.tools || [],
|
|
276
|
+
),
|
|
277
|
+
callTool: async (name, args) => tracked(
|
|
278
|
+
record('callTool', { name, arguments: args || {} }),
|
|
279
|
+
async () => {
|
|
280
|
+
const fixture = lookupToolFixture(name, args || {});
|
|
281
|
+
if (fixture !== undefined) {
|
|
282
|
+
return structuredClone(fixture);
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, result: null };
|
|
285
|
+
},
|
|
286
|
+
),
|
|
287
|
+
request: async (path, options) => tracked(
|
|
288
|
+
record('request', { path, options }),
|
|
289
|
+
async () => {
|
|
290
|
+
if (fixtures && fixtures.requests && Object.prototype.hasOwnProperty.call(fixtures.requests, path)) {
|
|
291
|
+
return structuredClone(fixtures.requests[path]);
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
},
|
|
295
|
+
),
|
|
240
296
|
};
|
|
241
297
|
}
|
|
242
298
|
|
|
@@ -270,32 +326,45 @@
|
|
|
270
326
|
function liveRuntime() {
|
|
271
327
|
return {
|
|
272
328
|
...stubRuntime(),
|
|
273
|
-
listTools: async () =>
|
|
274
|
-
record('listTools', {})
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
},
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
329
|
+
listTools: async () => tracked(
|
|
330
|
+
record('listTools', {}),
|
|
331
|
+
async () => {
|
|
332
|
+
const result = await runtimeQuery({ method: 'tools/list' });
|
|
333
|
+
return result.tools || [];
|
|
334
|
+
},
|
|
335
|
+
),
|
|
336
|
+
callTool: async (name, args) => tracked(
|
|
337
|
+
record('callTool', { name, arguments: args || {} }),
|
|
338
|
+
async () => runtimeQuery({ method: 'tools/call', name, arguments: args || {} }),
|
|
339
|
+
),
|
|
340
|
+
// Live mode must not inherit the stub's canned answer: it would settle
|
|
341
|
+
// ok: true with fake data and soften the all-calls-failed assertion.
|
|
342
|
+
cloudComputerFacts: async (options) => tracked(
|
|
343
|
+
record('cloudComputerFacts', options || {}),
|
|
344
|
+
async () => runtimeQuery({
|
|
345
|
+
method: 'cloud_computer/facts',
|
|
346
|
+
...(options && options.refresh ? { refresh: true } : {}),
|
|
347
|
+
}),
|
|
348
|
+
),
|
|
349
|
+
request: async (path, options) => tracked(
|
|
350
|
+
record('request', { path, options }),
|
|
351
|
+
async () => {
|
|
352
|
+
const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
|
|
353
|
+
method: (options && options.method) || 'GET',
|
|
354
|
+
headers: {
|
|
355
|
+
Authorization: `Bearer ${jwt}`,
|
|
356
|
+
...((options && options.body) ? { 'Content-Type': 'application/json' } : {}),
|
|
357
|
+
...((options && options.headers) || {}),
|
|
358
|
+
},
|
|
359
|
+
body: options && options.body ? JSON.stringify(options.body) : undefined,
|
|
360
|
+
});
|
|
361
|
+
const { payload, snippet } = await readJsonOrSnippet(response);
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
throw new Error((payload && (payload.error || payload.message)) || snippet || `Request failed with status ${response.status}`);
|
|
364
|
+
}
|
|
365
|
+
return payload;
|
|
366
|
+
},
|
|
367
|
+
),
|
|
299
368
|
};
|
|
300
369
|
}
|
|
301
370
|
|