@notis_ai/cli 0.2.9 → 0.2.10
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/package.json +1 -1
- package/skills/notis-apps/SKILL.md +2 -2
- package/skills/notis-cli/SKILL.md +8 -5
- package/src/command-specs/apps.js +33 -3
- package/src/command-specs/diagnostics.js +6 -1
- package/src/command-specs/helpers.js +7 -1
- package/src/runtime/cli-mode.generated.js +4 -3
- package/src/runtime/cli-mode.js +13 -8
- package/src/runtime/oauth.js +17 -1
- package/src/runtime/profiles.js +53 -15
package/package.json
CHANGED
|
@@ -470,13 +470,13 @@ Runs the real desktop-local development workflow. The CLI should discover all ap
|
|
|
470
470
|
|
|
471
471
|
## Deploy Without Backend Server
|
|
472
472
|
|
|
473
|
-
If the
|
|
473
|
+
If the live API is unreachable, use `--direct`:
|
|
474
474
|
|
|
475
475
|
```bash
|
|
476
476
|
npx --package @notis_ai/cli@latest -- notis apps deploy --direct
|
|
477
477
|
```
|
|
478
478
|
|
|
479
|
-
This uploads the bundle and editable source snapshot directly to Supabase storage and updates the app manifest in the database, bypassing the
|
|
479
|
+
This uploads the bundle and editable source snapshot directly to Supabase storage and updates the app manifest in the database, bypassing the API server. The CLI auto-falls back to direct mode on network errors. Localhost backends are reserved for `/notis-tests` via `./dev.sh`; do not retarget the personal CLI lane at loopback from this skill.
|
|
480
480
|
|
|
481
481
|
## Testing
|
|
482
482
|
|
|
@@ -266,15 +266,18 @@ desktop session yourself.
|
|
|
266
266
|
|
|
267
267
|
### Deploy fails with "network_error" or "fetch failed"
|
|
268
268
|
|
|
269
|
-
The
|
|
269
|
+
The CLI defaults to the live Notis API (`https://api.notis.ai`, or
|
|
270
|
+
`https://api-beta.notis.ai` when the signed-in user is on beta). Solutions:
|
|
270
271
|
|
|
271
|
-
1.
|
|
272
|
-
2.
|
|
273
|
-
3.
|
|
272
|
+
1. Run `npx --package @notis_ai/cli@latest -- notis doctor` and confirm `api_base` is a live Notis host
|
|
273
|
+
2. Use `--direct` for app deploys when you only need Supabase storage upload: `npx --package @notis_ai/cli@latest -- notis apps deploy --direct`
|
|
274
|
+
3. If auth looks stale, open Notis Desktop (or `Notis Beta`), wait for it to restore the session, then retry
|
|
275
|
+
|
|
276
|
+
Localhost backends are a Notis-developer test lane only. Do not retarget the CLI at loopback from this skill — that path is owned by `/notis-tests` via `./dev.sh` and the worktree runtime lease.
|
|
274
277
|
|
|
275
278
|
### `npx --package @notis_ai/cli@latest -- notis doctor` shows health/tool_roundtrip errors
|
|
276
279
|
|
|
277
|
-
The CLI health check pings the
|
|
280
|
+
The CLI health check pings the configured live API. App development commands that are `backend_call: local` (`init`, `build`, `verify`, `link`, `doctor`) work offline. `dev`, `pull`, `create`, `list`, and normal `deploy` need the live API; `deploy --direct` can bypass it when Supabase credentials are available.
|
|
278
281
|
|
|
279
282
|
### Stale bundle in the portal after deploy
|
|
280
283
|
|
|
@@ -561,6 +561,20 @@ export async function ensureDevInstall({
|
|
|
561
561
|
const skills = resolveConfiguredAppSkills(appConfig, projectDir);
|
|
562
562
|
let linkedState = readLinkedState(projectDir);
|
|
563
563
|
let linkedApp = null;
|
|
564
|
+
if (linkedState?.dev_app_id) {
|
|
565
|
+
// Stale cross-runtime / deleted dev apps often surface as PostgREST PGRST116
|
|
566
|
+
// zero-row errors from LOCAL_NOTIS_GET_APP (.single()), not exact "App not found".
|
|
567
|
+
// Treat those as absence so ensure can clear and reprovision. Installed app_id
|
|
568
|
+
// verification below stays fail-closed on unstructured DB errors.
|
|
569
|
+
const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool, {
|
|
570
|
+
treatPostgrestAbsenceAsMissing: true,
|
|
571
|
+
});
|
|
572
|
+
if (!devApp || devApp.manifest?.is_dev !== true) {
|
|
573
|
+
const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
|
|
574
|
+
linkedState = rest;
|
|
575
|
+
writeLinkedState(projectDir, linkedState);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
564
578
|
if (linkedState?.app_id) {
|
|
565
579
|
linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
|
|
566
580
|
if (linkedApp?.manifest?.is_dev === true) {
|
|
@@ -627,11 +641,19 @@ function databaseMaterializationWarnings(apps) {
|
|
|
627
641
|
return warnings;
|
|
628
642
|
}
|
|
629
643
|
|
|
630
|
-
|
|
644
|
+
function isPostgrestNoRowsMessage(message) {
|
|
645
|
+
const text = String(message || '');
|
|
646
|
+
return /\bPGRST116\b/.test(text)
|
|
647
|
+
|| /\b0 rows\b/i.test(text)
|
|
648
|
+
|| /no rows returned/i.test(text);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function getAccessibleApp(runtime, appId, runTool = runToolCommand, options = {}) {
|
|
652
|
+
const { treatPostgrestAbsenceAsMissing = false } = options;
|
|
631
653
|
const result = await runTool({
|
|
632
654
|
runtime,
|
|
633
655
|
toolName: GET_APP_TOOL,
|
|
634
|
-
arguments_: { app_id: appId },
|
|
656
|
+
arguments_: { app_id: appId, include_documents: false },
|
|
635
657
|
});
|
|
636
658
|
if (result.payload?.app) {
|
|
637
659
|
return {
|
|
@@ -640,7 +662,15 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
|
640
662
|
};
|
|
641
663
|
}
|
|
642
664
|
const message = typeof result.payload?.message === 'string' ? result.payload.message : '';
|
|
643
|
-
|
|
665
|
+
const errorCode = result.payload?.code || result.payload?.error?.code;
|
|
666
|
+
if (
|
|
667
|
+
result.payload?.status === 'error'
|
|
668
|
+
&& (
|
|
669
|
+
errorCode === 'app_not_found'
|
|
670
|
+
|| /^App not found\.?$/i.test(message.trim())
|
|
671
|
+
|| (treatPostgrestAbsenceAsMissing && isPostgrestNoRowsMessage(message))
|
|
672
|
+
)
|
|
673
|
+
) {
|
|
644
674
|
return null;
|
|
645
675
|
}
|
|
646
676
|
throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { usageError } from '../runtime/errors.js';
|
|
4
4
|
import {
|
|
@@ -178,6 +178,11 @@ async function executeReadOnlySql(ctx, query, phase) {
|
|
|
178
178
|
tools: [{ tool_slug: toolName, arguments: { query } }],
|
|
179
179
|
},
|
|
180
180
|
mutating: false,
|
|
181
|
+
// Always a fresh key, never the operator's --idempotency-key: a diagnostic
|
|
182
|
+
// must observe current state rather than replay a cached response, and two
|
|
183
|
+
// different queries under one reused key would collide on the request hash.
|
|
184
|
+
idempotencyKey: randomUUID(),
|
|
185
|
+
sendIdempotencyKeyWhenReading: true,
|
|
181
186
|
});
|
|
182
187
|
if (result.payload?.successful === false || result.payload?.error) {
|
|
183
188
|
throw usageError(
|
|
@@ -56,12 +56,18 @@ export async function runToolCommand({
|
|
|
56
56
|
mutating = false,
|
|
57
57
|
idempotencyKey,
|
|
58
58
|
fileBindings = [],
|
|
59
|
+
sendIdempotencyKeyWhenReading = false,
|
|
59
60
|
}) {
|
|
61
|
+
// The server owns effect classification and requires a key whenever *its*
|
|
62
|
+
// metadata says write or unknown — a client-side `mutating: false` hint does
|
|
63
|
+
// not exempt the call. Callers that knowingly dispatch through an
|
|
64
|
+
// unknown-classified wrapper (e.g. COMPOSIO_MULTI_EXECUTE_TOOL) opt in so the
|
|
65
|
+
// request carries a key instead of being rejected as idempotency_key_required.
|
|
60
66
|
const result = await callTool({
|
|
61
67
|
runtime: { ...runtime, mutating },
|
|
62
68
|
toolName,
|
|
63
69
|
arguments_,
|
|
64
|
-
idempotencyKey: mutating ? idempotencyKey : null,
|
|
70
|
+
idempotencyKey: mutating || sendIdempotencyKeyWhenReading ? idempotencyKey : null,
|
|
65
71
|
fileBindings,
|
|
66
72
|
});
|
|
67
73
|
return result;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Auto-generated on npm publish.
|
|
2
|
-
// Committed value is '
|
|
3
|
-
//
|
|
4
|
-
|
|
2
|
+
// Committed value is 'published' so the CLI defaults to the live Notis API.
|
|
3
|
+
// Localhost overrides come only from the worktree test lease (`./dev.sh`).
|
|
4
|
+
// scripts/set-cli-mode.js can rewrite this for publish/labeling experiments.
|
|
5
|
+
export const MODE = 'published';
|
package/src/runtime/cli-mode.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI mode detection for `notis apps dev`.
|
|
3
3
|
*
|
|
4
|
-
* The repo-local
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* that isn't running).
|
|
4
|
+
* The published npm CLI and the repo-local checkout both default to the live
|
|
5
|
+
* Notis API (`api.notis.ai` / `api-beta.notis.ai`). Localhost is not a CLI
|
|
6
|
+
* default — the `/notis-tests` worktree lease (`./dev.sh`) is the only
|
|
7
|
+
* supported loopback override for Notis developers.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
9
|
+
* Mode is baked into `cli-mode.generated.js` at publish time. The
|
|
10
|
+
* `NOTIS_CLI_MODE` env var is honored for internal labeling only (e.g. the
|
|
11
|
+
* `apps` auto-dev loop inside `./dev.sh`).
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { MODE as BAKED_MODE } from './cli-mode.generated.js';
|
|
@@ -20,8 +21,12 @@ export function getCliMode() {
|
|
|
20
21
|
return BAKED_MODE === 'published' ? 'published' : 'local';
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
export function getDefaultApiBase(mode = getCliMode()) {
|
|
24
|
-
|
|
24
|
+
export function getDefaultApiBase(mode = getCliMode(), { beta = false } = {}) {
|
|
25
|
+
// Mode no longer switches the default API to localhost. Local loopback is
|
|
26
|
+
// reserved for the worktree test lease; `mode` only affects portal origin
|
|
27
|
+
// labeling for the in-repo apps-dev helper.
|
|
28
|
+
void mode;
|
|
29
|
+
return beta ? 'https://api-beta.notis.ai' : 'https://api.notis.ai';
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function getDefaultPortalOrigin(mode = getCliMode()) {
|
package/src/runtime/oauth.js
CHANGED
|
@@ -668,12 +668,28 @@ async function exchangeCode(metadata, {
|
|
|
668
668
|
function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
|
|
669
669
|
const now = Math.floor(Date.now() / 1000);
|
|
670
670
|
const payload = decodeJwtPayload(tokenResponse.access_token);
|
|
671
|
+
const oauthApiBase = (metadata.apiBase || runtime.apiBase || '').replace(/\/+$/, '');
|
|
672
|
+
let beta;
|
|
673
|
+
try {
|
|
674
|
+
const hostname = new URL(oauthApiBase).hostname;
|
|
675
|
+
if (hostname === 'api-beta.notis.ai') beta = true;
|
|
676
|
+
else if (hostname === 'api.notis.ai') beta = false;
|
|
677
|
+
} catch {
|
|
678
|
+
beta = undefined;
|
|
679
|
+
}
|
|
671
680
|
const config = updateConfig((latest) => {
|
|
672
681
|
const next = ensureProfile(latest, runtime.profileName);
|
|
673
682
|
const profile = next.profiles[runtime.profileName];
|
|
674
683
|
next.profiles[runtime.profileName] = {
|
|
675
684
|
...profile,
|
|
676
|
-
|
|
685
|
+
// Keep Desktop's api_base intact when an independent OAuth grant refreshes.
|
|
686
|
+
// Only seed api_base from OAuth when the profile has no live API yet.
|
|
687
|
+
api_base:
|
|
688
|
+
profile.api_base && !/^https?:\/\/(localhost|127\.0\.0\.1|::1)(:|\/|$)/i.test(profile.api_base)
|
|
689
|
+
? profile.api_base
|
|
690
|
+
: (oauthApiBase || profile.api_base),
|
|
691
|
+
beta: typeof profile.beta === 'boolean' ? profile.beta : beta,
|
|
692
|
+
oauth_api_base: oauthApiBase || profile.oauth_api_base,
|
|
677
693
|
oauth_resource: metadata.resource,
|
|
678
694
|
oauth_access_token: tokenResponse.access_token,
|
|
679
695
|
oauth_refresh_token: tokenResponse.refresh_token || profile.oauth_refresh_token,
|
package/src/runtime/profiles.js
CHANGED
|
@@ -17,14 +17,12 @@ export const CONFIG_DIR = join(homedir(), '.notis');
|
|
|
17
17
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
18
18
|
export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
|
|
19
19
|
export const DEFAULT_API_BASE = 'https://api.notis.ai';
|
|
20
|
+
export const BETA_API_BASE = 'https://api-beta.notis.ai';
|
|
20
21
|
export const DEFAULT_PROFILE = 'default';
|
|
21
22
|
const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
|
|
22
23
|
const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
|
|
23
|
-
const LOCAL_DEFAULT_API_BASES = new Set([
|
|
24
|
-
'http://localhost:3001',
|
|
25
|
-
'http://127.0.0.1:3001',
|
|
26
|
-
]);
|
|
27
24
|
const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
25
|
+
const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
|
|
28
26
|
// Cross-process write lock over ~/.notis/config.json. Notis Desktop implements
|
|
29
27
|
// the same protocol independently in electron/src/cli-auth.ts (updateConfig);
|
|
30
28
|
// the `${configFile}.write-lock` directory name and these three timings must
|
|
@@ -51,6 +49,7 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
51
49
|
profiles[name] = {
|
|
52
50
|
jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
|
|
53
51
|
api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
|
|
52
|
+
beta: typeof profile.beta === 'boolean' ? profile.beta : undefined,
|
|
54
53
|
auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
|
|
55
54
|
refresh_token:
|
|
56
55
|
typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
|
|
@@ -105,6 +104,7 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
105
104
|
[DEFAULT_PROFILE]: {
|
|
106
105
|
jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
|
|
107
106
|
api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
|
|
107
|
+
beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
|
|
108
108
|
auth_mode: raw.auth_mode === 'dev_portal' ? raw.auth_mode : undefined,
|
|
109
109
|
refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
|
|
110
110
|
access_expires_at:
|
|
@@ -425,6 +425,48 @@ function isLocalApiBase(value) {
|
|
|
425
425
|
}
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
function isLiveApiBase(value) {
|
|
429
|
+
if (typeof value !== 'string' || !value) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const parsed = new URL(value);
|
|
434
|
+
return parsed.protocol === 'https:' && LIVE_API_HOSTS.has(parsed.hostname);
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Pick the live Notis API for this profile.
|
|
442
|
+
*
|
|
443
|
+
* Beta users (`users.beta = true`, mirrored onto the CLI profile by Desktop
|
|
444
|
+
* sync / OAuth against api-beta) hit api-beta.notis.ai; everyone else hits
|
|
445
|
+
* api.notis.ai. Localhost is never a default — only the worktree test lease
|
|
446
|
+
* (`./dev.sh` / `/notis-tests`) may retarget the CLI at loopback.
|
|
447
|
+
*/
|
|
448
|
+
export function resolveDefaultLiveApiBase(profile = {}) {
|
|
449
|
+
if (profile.beta === true) {
|
|
450
|
+
return BETA_API_BASE;
|
|
451
|
+
}
|
|
452
|
+
if (profile.beta === false) {
|
|
453
|
+
return DEFAULT_API_BASE;
|
|
454
|
+
}
|
|
455
|
+
if (profile.desktop_app_name === 'Notis Beta') {
|
|
456
|
+
return BETA_API_BASE;
|
|
457
|
+
}
|
|
458
|
+
if (isLiveApiBase(profile.api_base)) {
|
|
459
|
+
try {
|
|
460
|
+
if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
|
|
461
|
+
return BETA_API_BASE;
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// fall through
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return DEFAULT_API_BASE;
|
|
468
|
+
}
|
|
469
|
+
|
|
428
470
|
export function getApiBase(config, profileName, override) {
|
|
429
471
|
if (override) {
|
|
430
472
|
return override;
|
|
@@ -435,20 +477,16 @@ export function getApiBase(config, profileName, override) {
|
|
|
435
477
|
}
|
|
436
478
|
const profile = getProfile(config, profileName);
|
|
437
479
|
const profileApiBase = profile.api_base;
|
|
438
|
-
const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
|
|
439
480
|
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
(!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase) || isLocalApiBase(profileApiBase))
|
|
447
|
-
) {
|
|
448
|
-
return `http://localhost:${conductorPort + 1}`;
|
|
481
|
+
// Prefer an explicit non-loopback API stored on the profile (Desktop sync /
|
|
482
|
+
// OAuth / custom overrides). Ignore stale localhost values — loopback
|
|
483
|
+
// routing is owned exclusively by the worktree runtime lease under
|
|
484
|
+
// `/notis-tests`, not by CONDUCTOR_PORT or leftover local profile state.
|
|
485
|
+
if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
|
|
486
|
+
return profileApiBase.replace(/\/+$/, '');
|
|
449
487
|
}
|
|
450
488
|
|
|
451
|
-
return
|
|
489
|
+
return resolveDefaultLiveApiBase(profile);
|
|
452
490
|
}
|
|
453
491
|
|
|
454
492
|
export function getJwt(config, profileName) {
|