@notis_ai/cli 0.2.0-beta.114.1 → 0.2.0-beta.116.1
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
CHANGED
|
@@ -9,8 +9,6 @@ Use this skill when the user wants a packaged Notis app -- task manager, CRM, da
|
|
|
9
9
|
|
|
10
10
|
Run the Notis CLI through NPX, for example `npx --package @notis_ai/cli@latest -- notis apps list`. Notis Desktop keeps the CLI auth profile current. This `notis-apps` skill is delivered through normal Notis skill sync for the signed-in user, alongside other curated skills.
|
|
11
11
|
|
|
12
|
-
For the platform model, architecture, and local development workflow, read [docs/notis-apps-platform.md](../../../docs/notis-apps-platform.md) first. This skill is the execution guide for using that platform correctly.
|
|
13
|
-
|
|
14
12
|
## How Apps Are Built
|
|
15
13
|
|
|
16
14
|
All Notis apps are built using the Notis CLI, either locally in a repo workspace or inside a Vercel Sandbox. The platform contract is the same in both cases:
|
|
@@ -94,7 +92,7 @@ App code never accesses the runtime directly -- it uses SDK hooks (`useTool`, `u
|
|
|
94
92
|
15. **Local development first; deploy is user-gated** -- Iterate with `apps dev` and let the **user** test the app in the desktop **Local development** sidebar group. Do NOT run `apps create` or `apps deploy` on your own initiative, even after a clean build and verify. `deploy` installs the app onto the user's account and is a one-directional, outward-facing action — treat it like publishing: only run it when the user has tested the local build and **explicitly asks you to deploy**. Building a new app end-to-end without deploying is the expected, complete outcome. (See the **Local-development-first handoff** in the Workflow.)
|
|
95
93
|
16. **Installed app links are explicit** -- A mounted dev session updates an installed workspace app only when the local checkout is linked by app id in `.notis/state.json` and the dev-session registry mirrors that id. Name or slug matches may be suggestions, never update targets. After first install, keep that link so Portal and CLI show/update the same app instead of creating duplicates.
|
|
96
94
|
17. **Development identities stay separate** -- `.notis/state.json` uses `dev_app_id` for the hidden development-runtime row and `app_id` only for an accessible installed workspace app. The Electron registry mirrors the installed id as `targetAppId`. Never pass a runtime app whose manifest has `is_dev: true` to `notis apps link`; it is not an install/update target. Current CLIs reject that link and repair stale hidden, deleted, or inaccessible targets on the next `apps dev` without erasing valid state on transport or authentication failures.
|
|
97
|
-
18. **Mounted means Portal-acknowledged** -- A running process
|
|
95
|
+
18. **Mounted means Portal-acknowledged** -- A running process or HTTP 200 proves only that the app is serving. Before telling the user an app is mounted, require the current `apps dev` process to report `Mounted in <target desktop>`. The CLI selects the target from the active Notis profile: normal CLI runs target the signed-in Notis or Notis Beta desktop, while a CLI running inside an active Notis source workspace targets that workspace's matching desktop instance. The exact session/app/slug/nonce acknowledgment is accepted only from the visible target window after the app enters its final **Local development** sidebar model. Route rendering is a separate proof: when UI verification is required, also open the app and require `Rendered in <target desktop>`.
|
|
98
96
|
19. **Store submission is user-gated** -- Run `apps publish --confirm-ready` only after the user explicitly confirms the current App Details page and Store listing are ready. Deploy the exact approved local state first. The command must reject missing confirmation, incomplete listing media, a local/deployed version mismatch, private visibility, or an existing pending review.
|
|
99
97
|
20. **Bump `notisAppVersion` for every Store update** -- `package.json` must contain a semver `notisAppVersion`. For an existing Store app, increment it beyond the currently published registry version before deploy and submission; registry CI rejects equal or lower versions.
|
|
100
98
|
21. **`CHANGELOG.md` owns release history** -- Keep the complete release history in one root `CHANGELOG.md`, newest entry first. Do not add new `versionNotes` values to `notis.config.ts`. Use `## [Release title] - YYYY-MM-DD`, or `{PR_MERGE_DATE}` for an unpublished entry. App Details reads **What’s New** and **Version History** from the deployed package manifest, while the Store reads them from the latest published snapshot; unpublished workspace edits must never change the Store page. The manifest also exposes `package.json` `notisAppVersion` as the package version shown in App Details.
|
|
@@ -126,13 +124,13 @@ These are the most common mistakes agents make. Each one wastes time and produce
|
|
|
126
124
|
2. **Pull only installed apps.** If the user explicitly wants to fork an app they already installed, run `npx --package @notis_ai/cli@latest -- notis apps list`, then `npx --package @notis_ai/cli@latest -- notis apps pull <app-id> ./<dir>`. To fork a Store app that is not installed, tell the user to install it from `/store` first.
|
|
127
125
|
3. **Edit the listing source.** Update `name` (slug), `title`, description, icon, accent, author, categories, tagline, databases, routes, and tools in `notis.config.ts`. Declare a database as a string for schema-only Store packaging; use `{ slug: 'templates', seedDocuments: true }` only when its rows are deliberate starter content for every installer. Keep the complete Store release history in the root `CHANGELOG.md`, newest entry first, using `## [Release title] - YYYY-MM-DD` (or `{PR_MERGE_DATE}` before publication). The first entry powers **What’s New** and the same file powers **Version History**. `icon` is a `phosphor:<name>` value or `metadata/icon.png`; when unset the app shows its **two-letter initials** everywhere (store, sidebar, app details). `accent` optionally pins the avatar color to one of `blue|violet|emerald|amber|rose|sky|fuchsia|teal` (default derived from the app id). Icon/accent flow through deploy onto the app row + listing and can also be set later via the `update_app` tool.
|
|
128
126
|
4. **Build pages in `app/`.** Reuse scaffold code wherever it fits.
|
|
129
|
-
5. **Iterate live.** Run `npx --package @notis_ai/cli@latest -- notis apps dev` so the desktop
|
|
127
|
+
5. **Iterate live.** Run `npx --package @notis_ai/cli@latest -- notis apps dev` so the target desktop's **Local development** sidebar group discovers the app and renders the local bundle. Keep this command running for as long as the user is testing; stopping it removes the temporary Local development entry. Read the command's `Target desktop` line instead of guessing between Notis, Notis Beta, or a source-workspace desktop.
|
|
130
128
|
6. **Capture listing screenshots.** Declare 3–6 screenshots in `notis.config.ts`, each with a stable `path`, descriptive `alt`, and optional `route`/`scenario`/`focus`/`theme`, then run `npx --package @notis_ai/cli@latest -- notis apps screenshot`. Use `focus` to frame a real app root without empty browser canvas; use `theme: 'light'` or `theme: 'dark'` to match both the Portal render and Store backdrop, and pair both modes when that best represents the app. It renders the configured states in a headless harness and writes exact 2000x1250 PNGs under `metadata/`, using the deterministic Store presentation by default (`--raw` is diagnostic only). Apps are icon-led like Raycast — the icon set in `notis.config.ts` represents the app, so there is no cover image, only these screenshots. Never hand-author the PNGs; regenerate them when routes or UI change.
|
|
131
129
|
7. **Verify locally.** Run `npm install`, then `npx --package @notis_ai/cli@latest -- notis apps build` and `npx --package @notis_ai/cli@latest -- notis apps verify`. Surface the verify report and fix failures.
|
|
132
|
-
8. **Local-development-first handoff — STOP HERE.** Keep `apps dev` running and hand off to the user: tell them the app is live in the desktop **Local development** sidebar group (green `DEV` badge) and ask them to test it there. Building a new app to this point, without deploying, is a **complete and expected** result. Do NOT proceed to `apps create` / `apps deploy` yet — wait for the user to test and explicitly ask to deploy. (`apps dev` is what puts the app in Local development; without a running session the app never appears there.) **Before handing off, complete all three acceptance checks:**
|
|
133
|
-
1.
|
|
134
|
-
2. Bundle: the
|
|
135
|
-
3. Mount
|
|
130
|
+
8. **Local-development-first handoff — STOP HERE.** Keep `apps dev` running and hand off to the user: tell them the app is live in the target desktop's **Local development** sidebar group (green `DEV` badge) and ask them to test it there. Building a new app to this point, without deploying, is a **complete and expected** result. Do NOT proceed to `apps create` / `apps deploy` yet — wait for the user to test and explicitly ask to deploy. (`apps dev` is what puts the app in Local development; without a running session the app never appears there.) **Before handing off, complete all three acceptance checks:**
|
|
131
|
+
1. Target: capture the CLI's `Target desktop: <name>` line and make sure that exact desktop app is running and signed in.
|
|
132
|
+
2. Bundle: the reported loopback `/snapshot` URL responds successfully and contains the expected manifest/routes.
|
|
133
|
+
3. Mount and render: require `Mounted in <target desktop>: <app name>`. If the task includes UI or runtime behavior, open the default route and also require `Rendered in <target desktop>: <app name>`. If the CLI says only `Serving locally`, do not claim the app is mounted.
|
|
136
134
|
See Troubleshooting → *App is missing from Local development* if any check fails.
|
|
137
135
|
9. **Deploy only when the user asks.** Once the user has tested locally and explicitly requests a deploy, run `npx --package @notis_ai/cli@latest -- notis apps create "<name>" .` (first time) then `npx --package @notis_ai/cli@latest -- notis apps deploy --direct`, or link first with `npx --package @notis_ai/cli@latest -- notis apps link <id> .` / pass `--app-id <id>` for an existing app. Deploy installs or updates the app on the user's account (it appears under **Workspace**, not Local development). After first install, `.notis/state.json` must point at the installed app id so future local-dev actions become **Update**, not another **Install**.
|
|
138
136
|
10. **Submit only after confirmation.** When the user explicitly confirms the current App Details page is ready, ensure the approved state is deployed, then run `npx --package @notis_ai/cli@latest -- notis apps publish --confirm-ready`. The command submits Team apps immediately or opens the Public Store registry review PR. Without that confirmation, stop after deploy.
|
|
@@ -484,7 +482,7 @@ This uploads the bundle and editable source snapshot directly to Supabase storag
|
|
|
484
482
|
|
|
485
483
|
1. **Build validation**: `npx --package @notis_ai/cli@latest -- notis apps build` must succeed without errors. Vite surfaces TypeScript and bundling errors during this step.
|
|
486
484
|
2. **Headless render verification** (recommended after every build): run `npx --package @notis_ai/cli@latest -- notis apps verify`. It builds unless `--skip-build` is passed, spins up a loopback harness, drives `agent-browser` against every route, and reports per-route pass/fail with captured render errors and runtime calls.
|
|
487
|
-
3. **Local development acceptance**: Require the running CLI to report `Mounted in
|
|
485
|
+
3. **Local development acceptance**: Require the running CLI to name the intended desktop and report `Mounted in <target desktop>`, which proves the exact nonce-backed session entered that visible desktop's final `Local development` sidebar model. Bundle HTTP health alone proves only `Serving locally`. When the task includes UI, runtime behavior, or visual acceptance, open the default route and also require `Rendered in <target desktop>` before claiming the app works.
|
|
488
486
|
4. **Post-deploy**: Verify the deployed bundle via `/portal_views/get` -> `runtime_descriptor.bundle.js_url`, then verify the app renders in the portal. The portal renders app bundles directly as React components, so the fastest verification is navigating to the app page in the portal.
|
|
489
487
|
|
|
490
488
|
### Headless harness verification
|
|
@@ -511,15 +509,6 @@ Run `npx --package @notis_ai/cli@latest -- notis apps verify` after `npx --packa
|
|
|
511
509
|
|
|
512
510
|
- **Deploy fails with network error**: Backend server not running. Use `npx --package @notis_ai/cli@latest -- notis apps deploy --direct` or start the server.
|
|
513
511
|
- **App shows old code after deploy**: Bundle cache is stale. Hard refresh (Cmd+Shift+R) or clear site data in DevTools.
|
|
514
|
-
- **App is missing from Local development**:
|
|
515
|
-
```bash
|
|
516
|
-
cat "$CONDUCTOR_WORKSPACE_PATH/.context/app-dev-sessions.json" # your devSlug must be present with a fresh lastHeartbeatAt
|
|
517
|
-
```
|
|
518
|
-
If it is missing, restart `apps dev` pointed at that file:
|
|
519
|
-
```bash
|
|
520
|
-
NOTIS_APP_DEV_SESSIONS_FILE="$CONDUCTOR_WORKSPACE_PATH/.context/app-dev-sessions.json" \
|
|
521
|
-
npx --package @notis_ai/cli@latest -- notis apps dev --no-open
|
|
522
|
-
```
|
|
523
|
-
Current CLI auto-resolves the nearest `.context` registry, so the explicit env is only a fallback for older CLIs. Startup and heartbeat preserve other sessions, so this coexists with the desktop's own dev sessions. If the registry row has a `targetAppId`, that id must resolve to an installed app whose manifest is not `is_dev: true`; never repair this by linking to another development-runtime id. Restart the current CLI and let it clear stale hidden/deleted targets automatically. Then wait for `Mounted in Notis`; Electron writes the matching nonce acknowledgment beside the registry only after the app reaches the final Local development sidebar model. A corrected registry row or HTTP 200 is not mount proof by itself. Open and render the route separately only when validating UI or runtime behavior.
|
|
512
|
+
- **App is missing from Local development**: Read the `Target desktop` line from `apps dev`, then bring that exact Notis app forward and confirm it is signed into the same account reported by `npx --package @notis_ai/cli@latest -- notis whoami`. Keep `apps dev` running. If it still says only `Serving locally`, run `npx --package @notis_ai/cli@latest -- notis doctor`, restart the target desktop, and retry `apps dev`. Do not redirect internal registry files manually: the CLI selects the normal Notis/Notis Beta target from the active profile and selects a source-workspace desktop only when an active workspace runtime identifies it. Wait for `Mounted in <target desktop>` before claiming success; when validating UI or runtime behavior, open the route and wait for `Rendered in <target desktop>` too.
|
|
524
513
|
- **`LOCAL_NOTIS_DATABASE_QUERY` returns empty documents**: Check that the database ID passed to the tool matches the intended database. Use `npx --package @notis_ai/cli@latest -- notis tools exec LOCAL_NOTIS_DATABASE_LIST_DATABASES --arguments '{}'` to verify the ID; use the database slug only as a fallback.
|
|
525
514
|
- **Properties are `undefined`**: Keep app-local result types for `useTool<TArgs, TResult>` and guard optional nested properties when reading live data.
|
|
@@ -44,10 +44,12 @@ import {
|
|
|
44
44
|
runHarnessRoute,
|
|
45
45
|
} from '../runtime/agent-browser.js';
|
|
46
46
|
import {
|
|
47
|
+
getAppDevSessionsFile,
|
|
47
48
|
heartbeatAppDevSession,
|
|
48
49
|
removeAppDevSession,
|
|
49
50
|
upsertAppDevSessions,
|
|
50
51
|
waitForAppDevSessionMountAcknowledgements,
|
|
52
|
+
waitForAppDevSessionRenderAcknowledgements,
|
|
51
53
|
} from '../runtime/app-dev-sessions.js';
|
|
52
54
|
import { getAvailablePort } from '../runtime/ports.js';
|
|
53
55
|
import { getCliMode } from '../runtime/cli-mode.js';
|
|
@@ -140,13 +142,24 @@ function decodeJwtSub(jwt) {
|
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
function
|
|
144
|
-
|
|
145
|
+
export function developmentDesktopOpenCommand(
|
|
146
|
+
url,
|
|
147
|
+
{
|
|
148
|
+
platform = process.platform,
|
|
149
|
+
appName = null,
|
|
150
|
+
bundleId = null,
|
|
151
|
+
scheme = 'notis',
|
|
152
|
+
} = {},
|
|
153
|
+
) {
|
|
145
154
|
let command;
|
|
146
155
|
let args;
|
|
147
156
|
if (platform === 'darwin') {
|
|
148
157
|
command = 'open';
|
|
149
|
-
args =
|
|
158
|
+
args = bundleId && scheme === 'notis'
|
|
159
|
+
? ['-b', bundleId, url]
|
|
160
|
+
: appName && scheme === 'notis'
|
|
161
|
+
? ['-a', appName, url]
|
|
162
|
+
: [url];
|
|
150
163
|
} else if (platform === 'win32') {
|
|
151
164
|
command = 'cmd';
|
|
152
165
|
args = ['/c', 'start', '', url];
|
|
@@ -154,10 +167,14 @@ function openInBrowser(url) {
|
|
|
154
167
|
command = 'xdg-open';
|
|
155
168
|
args = [url];
|
|
156
169
|
}
|
|
170
|
+
return { command, args };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function openInBrowser(url, options = {}) {
|
|
174
|
+
const { command, args } = developmentDesktopOpenCommand(url, options);
|
|
157
175
|
try {
|
|
158
|
-
const child = spawn(command, args, { stdio: 'ignore'
|
|
176
|
+
const child = spawn(command, args, { stdio: 'ignore' });
|
|
159
177
|
child.on('error', () => {});
|
|
160
|
-
child.unref();
|
|
161
178
|
} catch {
|
|
162
179
|
// Non-fatal. The URL is printed in the CLI output.
|
|
163
180
|
}
|
|
@@ -207,21 +224,55 @@ function pickDefaultRouteSlug(manifest) {
|
|
|
207
224
|
|
|
208
225
|
const DESKTOP_DEEP_LINK_SCHEME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
209
226
|
|
|
210
|
-
export function resolveDevelopmentDesktopScheme(env = process.env) {
|
|
227
|
+
export function resolveDevelopmentDesktopScheme(env = process.env, worktreeRuntime = null) {
|
|
211
228
|
// Mirror the desktop app's own scheme resolution (electron main + forge config):
|
|
212
229
|
// local dev launches register `notis-dev`, while installed prod/beta builds claim
|
|
213
230
|
// `notis`. Hardcoding `notis` here is what made `apps dev` open the installed
|
|
214
231
|
// prod/beta app instead of the local dev app.
|
|
215
|
-
const scheme = (
|
|
232
|
+
const scheme = (
|
|
233
|
+
worktreeRuntime?.desktop_deep_link_scheme
|
|
234
|
+
|| env.NOTIS_DESKTOP_DEEP_LINK_SCHEME
|
|
235
|
+
|| ''
|
|
236
|
+
).trim();
|
|
216
237
|
return DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
|
|
217
238
|
}
|
|
218
239
|
|
|
240
|
+
export function resolveDevelopmentDesktopAppName(runtime = {}) {
|
|
241
|
+
const explicit = String(
|
|
242
|
+
runtime.desktopAppName
|
|
243
|
+
|| runtime.worktreeRuntime?.desktop_app_name
|
|
244
|
+
|| '',
|
|
245
|
+
).trim();
|
|
246
|
+
if (explicit) {
|
|
247
|
+
return explicit;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return new URL(runtime.apiBase).hostname === 'api-beta.notis.ai'
|
|
251
|
+
? 'Notis Beta'
|
|
252
|
+
: 'Notis';
|
|
253
|
+
} catch {
|
|
254
|
+
return 'Notis';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function resolveDevelopmentDesktopBundleId(runtime = {}) {
|
|
259
|
+
return resolveDevelopmentDesktopAppName(runtime) === 'Notis Beta'
|
|
260
|
+
? 'ai.notis.desktop.beta'
|
|
261
|
+
: 'ai.notis.desktop';
|
|
262
|
+
}
|
|
263
|
+
|
|
219
264
|
export function buildDevelopmentDesktopUrl(appHref = null, scheme = 'notis') {
|
|
220
265
|
const route = String(appHref || '/store').replace(/^\/+/, '');
|
|
221
266
|
const normalizedScheme = DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
|
|
222
267
|
return `${normalizedScheme}://${route || 'store'}`;
|
|
223
268
|
}
|
|
224
269
|
|
|
270
|
+
export function buildMountedDevelopmentDesktopUrl(appHref, scheme, sessionId) {
|
|
271
|
+
const url = new URL(buildDevelopmentDesktopUrl(appHref, scheme));
|
|
272
|
+
url.searchParams.set('notis_dev_session', sessionId);
|
|
273
|
+
return url.toString();
|
|
274
|
+
}
|
|
275
|
+
|
|
225
276
|
export function shouldOpenDevelopmentTab(options = {}) {
|
|
226
277
|
// Commander stores the negatable `--no-open` flag as `options.open === false`;
|
|
227
278
|
// it never sets `options.noOpen`. Reading the non-existent `noOpen` key meant
|
|
@@ -230,8 +281,17 @@ export function shouldOpenDevelopmentTab(options = {}) {
|
|
|
230
281
|
return options.open !== false;
|
|
231
282
|
}
|
|
232
283
|
|
|
233
|
-
function
|
|
234
|
-
|
|
284
|
+
export function buildDevelopmentAppHref({
|
|
285
|
+
appSlug,
|
|
286
|
+
appId,
|
|
287
|
+
devSlug,
|
|
288
|
+
targetAppId = null,
|
|
289
|
+
targetAppSlug = null,
|
|
290
|
+
manifest,
|
|
291
|
+
}) {
|
|
292
|
+
const routeAppId = `${targetAppId || appId}__local_dev__${devSlug}`;
|
|
293
|
+
const routeAppSlug = targetAppSlug || devSlug || appSlug;
|
|
294
|
+
const originlessBase = `/apps/${routeAppSlug}-${routeAppId}`;
|
|
235
295
|
const routeSlug = pickDefaultRouteSlug(manifest);
|
|
236
296
|
return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
|
|
237
297
|
}
|
|
@@ -499,8 +559,9 @@ export async function ensureDevInstall({
|
|
|
499
559
|
const manifest = buildManifestForDev(appConfig);
|
|
500
560
|
const skills = resolveConfiguredAppSkills(appConfig, projectDir);
|
|
501
561
|
let linkedState = readLinkedState(projectDir);
|
|
562
|
+
let linkedApp = null;
|
|
502
563
|
if (linkedState?.app_id) {
|
|
503
|
-
|
|
564
|
+
linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
|
|
504
565
|
if (linkedApp?.manifest?.is_dev === true) {
|
|
505
566
|
const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
|
|
506
567
|
const devAppId = linkedState.dev_app_id || legacyDevAppId;
|
|
@@ -512,6 +573,7 @@ export async function ensureDevInstall({
|
|
|
512
573
|
} : {}),
|
|
513
574
|
};
|
|
514
575
|
writeLinkedState(projectDir, linkedState);
|
|
576
|
+
linkedApp = null;
|
|
515
577
|
}
|
|
516
578
|
}
|
|
517
579
|
const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills });
|
|
@@ -544,6 +606,7 @@ export async function ensureDevInstall({
|
|
|
544
606
|
created: ensureResult.payload.created || false,
|
|
545
607
|
linkedAppId: linkedState?.app_id || null,
|
|
546
608
|
targetAppId: linkedState?.app_id || null,
|
|
609
|
+
targetAppSlug: linkedApp?.slug || null,
|
|
547
610
|
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
548
611
|
};
|
|
549
612
|
}
|
|
@@ -725,6 +788,15 @@ async function appsDevHandler(ctx) {
|
|
|
725
788
|
throw usageError('Could not determine the current user from the CLI auth token. Open the Notis desktop app, sign in, and retry.');
|
|
726
789
|
}
|
|
727
790
|
const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
|
|
791
|
+
const sessionsFilePath = getAppDevSessionsFile(
|
|
792
|
+
ctx.runtime.worktreeRuntime?.app_dev_sessions_file,
|
|
793
|
+
);
|
|
794
|
+
const desktopScheme = resolveDevelopmentDesktopScheme(
|
|
795
|
+
process.env,
|
|
796
|
+
ctx.runtime.worktreeRuntime,
|
|
797
|
+
);
|
|
798
|
+
const desktopAppName = resolveDevelopmentDesktopAppName(ctx.runtime);
|
|
799
|
+
const desktopBundleId = resolveDevelopmentDesktopBundleId(ctx.runtime);
|
|
728
800
|
const sessionId = randomUUID();
|
|
729
801
|
|
|
730
802
|
const candidates = [];
|
|
@@ -780,16 +852,25 @@ async function appsDevHandler(ctx) {
|
|
|
780
852
|
...app,
|
|
781
853
|
bundleBaseUrl,
|
|
782
854
|
mountNonce: randomUUID(),
|
|
783
|
-
appHref:
|
|
855
|
+
appHref: buildDevelopmentAppHref({
|
|
784
856
|
appSlug: app.slug,
|
|
785
857
|
appId: app.appId,
|
|
858
|
+
devSlug: app.devSlug,
|
|
859
|
+
targetAppId: app.targetAppId,
|
|
860
|
+
targetAppSlug: app.targetAppSlug,
|
|
786
861
|
manifest: app.manifest,
|
|
787
862
|
}),
|
|
788
863
|
};
|
|
789
864
|
});
|
|
790
865
|
const developmentTabUrl = buildDevelopmentDesktopUrl(
|
|
791
866
|
apps[0]?.appHref,
|
|
792
|
-
|
|
867
|
+
desktopScheme,
|
|
868
|
+
);
|
|
869
|
+
const desktopWakeUrl = buildDevelopmentDesktopUrl('/manage', desktopScheme);
|
|
870
|
+
const mountedDevelopmentTabUrl = buildMountedDevelopmentDesktopUrl(
|
|
871
|
+
apps[0]?.appHref,
|
|
872
|
+
desktopScheme,
|
|
873
|
+
sessionId,
|
|
793
874
|
);
|
|
794
875
|
const warnings = databaseMaterializationWarnings(apps);
|
|
795
876
|
|
|
@@ -802,6 +883,7 @@ async function appsDevHandler(ctx) {
|
|
|
802
883
|
userId: identity,
|
|
803
884
|
})),
|
|
804
885
|
port,
|
|
886
|
+
sessionsFilePath,
|
|
805
887
|
});
|
|
806
888
|
|
|
807
889
|
try {
|
|
@@ -818,7 +900,8 @@ async function appsDevHandler(ctx) {
|
|
|
818
900
|
projectDir: app.projectDir,
|
|
819
901
|
startedAt: now,
|
|
820
902
|
lastHeartbeatAt: now,
|
|
821
|
-
|
|
903
|
+
desktopAppName,
|
|
904
|
+
})), sessionsFilePath);
|
|
822
905
|
} catch (error) {
|
|
823
906
|
try {
|
|
824
907
|
await devServer.close();
|
|
@@ -830,7 +913,7 @@ async function appsDevHandler(ctx) {
|
|
|
830
913
|
|
|
831
914
|
let heartbeatTimer = setInterval(() => {
|
|
832
915
|
try {
|
|
833
|
-
heartbeatAppDevSession(sessionId, new Date().toISOString());
|
|
916
|
+
heartbeatAppDevSession(sessionId, new Date().toISOString(), sessionsFilePath);
|
|
834
917
|
} catch (error) {
|
|
835
918
|
const message = error instanceof Error ? error.message : String(error);
|
|
836
919
|
process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
|
|
@@ -852,6 +935,8 @@ async function appsDevHandler(ctx) {
|
|
|
852
935
|
development_url: developmentTabUrl,
|
|
853
936
|
session_id: sessionId,
|
|
854
937
|
mount_status: 'serving',
|
|
938
|
+
render_status: 'waiting_for_route',
|
|
939
|
+
desktop_target: desktopAppName,
|
|
855
940
|
identity,
|
|
856
941
|
apps: apps.map((app) => ({
|
|
857
942
|
slug: app.devSlug,
|
|
@@ -869,36 +954,67 @@ async function appsDevHandler(ctx) {
|
|
|
869
954
|
warnings,
|
|
870
955
|
humanSummary: [
|
|
871
956
|
`Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
|
|
957
|
+
`Target desktop: ${desktopAppName}`,
|
|
872
958
|
'',
|
|
873
959
|
`Open in desktop: ${developmentTabUrl}`,
|
|
874
960
|
'',
|
|
875
961
|
...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
|
|
876
962
|
'',
|
|
877
|
-
`Serving locally; waiting for
|
|
963
|
+
`Serving locally; waiting for ${desktopAppName} to mount ${apps.length === 1 ? 'the app' : `${apps.length} apps`}.`,
|
|
878
964
|
'',
|
|
879
965
|
'Press Ctrl-C to stop.',
|
|
880
966
|
].join('\n'),
|
|
881
967
|
});
|
|
882
968
|
|
|
883
969
|
if (shouldOpenDevelopmentTab(ctx.options)) {
|
|
884
|
-
openInBrowser(
|
|
970
|
+
openInBrowser(desktopWakeUrl, {
|
|
971
|
+
appName: desktopAppName,
|
|
972
|
+
bundleId: desktopBundleId,
|
|
973
|
+
scheme: desktopScheme,
|
|
974
|
+
});
|
|
885
975
|
}
|
|
886
976
|
|
|
887
977
|
void (async () => {
|
|
888
|
-
let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements
|
|
978
|
+
let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
|
|
979
|
+
sessionsFilePath,
|
|
980
|
+
});
|
|
889
981
|
if (!result.mounted) {
|
|
890
982
|
process.stderr.write(
|
|
891
|
-
`[notis apps dev] Serving locally, but
|
|
983
|
+
`[notis apps dev] Serving locally, but ${desktopAppName} has not acknowledged ${result.missing.length === 1 ? 'the app' : `${result.missing.length} apps`} in its Local development sidebar yet. Keep this command running and open ${desktopAppName}.\n`,
|
|
892
984
|
);
|
|
893
985
|
}
|
|
894
986
|
while (!result.mounted) {
|
|
895
987
|
result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
|
|
988
|
+
sessionsFilePath,
|
|
989
|
+
timeoutMs: 60_000,
|
|
990
|
+
pollIntervalMs: 250,
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
process.stderr.write(
|
|
994
|
+
`[notis apps dev] Mounted in ${desktopAppName}: ${apps.map((app) => app.name).join(', ')}.\n`,
|
|
995
|
+
);
|
|
996
|
+
if (shouldOpenDevelopmentTab(ctx.options)) {
|
|
997
|
+
openInBrowser(mountedDevelopmentTabUrl, {
|
|
998
|
+
appName: desktopAppName,
|
|
999
|
+
bundleId: desktopBundleId,
|
|
1000
|
+
scheme: desktopScheme,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const firstAppRender = expectedMountAcknowledgements.slice(0, 1);
|
|
1005
|
+
let renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
|
|
1006
|
+
sessionsFilePath,
|
|
1007
|
+
timeoutMs: 0,
|
|
1008
|
+
});
|
|
1009
|
+
while (!renderResult.mounted) {
|
|
1010
|
+
renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
|
|
1011
|
+
sessionsFilePath,
|
|
896
1012
|
timeoutMs: 60_000,
|
|
897
1013
|
pollIntervalMs: 250,
|
|
898
1014
|
});
|
|
899
1015
|
}
|
|
900
1016
|
process.stderr.write(
|
|
901
|
-
`[notis apps dev]
|
|
1017
|
+
`[notis apps dev] Rendered in ${desktopAppName}: ${apps[0].name}.\n`,
|
|
902
1018
|
);
|
|
903
1019
|
})().catch((error) => {
|
|
904
1020
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -915,7 +1031,7 @@ async function appsDevHandler(ctx) {
|
|
|
915
1031
|
heartbeatTimer = null;
|
|
916
1032
|
}
|
|
917
1033
|
try {
|
|
918
|
-
removeAppDevSession(sessionId);
|
|
1034
|
+
removeAppDevSession(sessionId, sessionsFilePath);
|
|
919
1035
|
} catch {
|
|
920
1036
|
// ignore cleanup failures during shutdown
|
|
921
1037
|
}
|
|
@@ -288,12 +288,13 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions,
|
|
|
288
288
|
/**
|
|
289
289
|
* Start the dev server for one or more apps.
|
|
290
290
|
*
|
|
291
|
-
* @param {{apps: Array<{slug: string, projectDir: string, appId?: string, targetAppId?: string, userId?: string}>, port: number, watch?: boolean, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
|
|
291
|
+
* @param {{apps: Array<{slug: string, projectDir: string, appId?: string, targetAppId?: string, userId?: string}>, port: number, watch?: boolean, sessionsFilePath?: string, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
|
|
292
292
|
*/
|
|
293
293
|
export async function startAppDevServer({
|
|
294
294
|
apps,
|
|
295
295
|
port,
|
|
296
296
|
watch = true,
|
|
297
|
+
sessionsFilePath,
|
|
297
298
|
harness = {},
|
|
298
299
|
log = (msg) => process.stdout.write(`${msg}\n`),
|
|
299
300
|
logError = (msg) => process.stderr.write(`${msg}\n`),
|
|
@@ -466,7 +467,7 @@ export async function startAppDevServer({
|
|
|
466
467
|
devSlug: state.slug,
|
|
467
468
|
targetAppId: appId,
|
|
468
469
|
lastHeartbeatAt: now,
|
|
469
|
-
});
|
|
470
|
+
}, sessionsFilePath);
|
|
470
471
|
const response = {
|
|
471
472
|
ok: true,
|
|
472
473
|
app_id: appId,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { dirname, join, parse as parsePath } from 'node:path';
|
|
5
4
|
import { CONFIG_DIR } from './profiles.js';
|
|
6
5
|
|
|
@@ -9,37 +8,6 @@ export const APP_DEV_SESSIONS_FILE = DEFAULT_APP_DEV_SESSIONS_FILE;
|
|
|
9
8
|
export const APP_DEV_SESSIONS_VERSION = 1;
|
|
10
9
|
export const APP_DEV_SESSION_MOUNT_ACKS_VERSION = 1;
|
|
11
10
|
|
|
12
|
-
/**
|
|
13
|
-
* Walk up from `startDir` looking for a workspace-scoped dev-sessions registry.
|
|
14
|
-
*
|
|
15
|
-
* The Notis desktop (and Conductor) reads a PER-WORKSPACE registry at
|
|
16
|
-
* `<workspace>/.context/app-dev-sessions.json` (it points its own `apps dev`
|
|
17
|
-
* at it via `NOTIS_APP_DEV_SESSIONS_FILE`). When a coding agent runs
|
|
18
|
-
* `apps dev` manually it does not inherit that env var, so its session would
|
|
19
|
-
* otherwise land in the global `~/.notis` registry and never appear in the
|
|
20
|
-
* desktop's Local development sidebar. Resolving the nearest `.context`
|
|
21
|
-
* registry keeps agent-run and desktop-run sessions in the same file.
|
|
22
|
-
*
|
|
23
|
-
* Returns the registry path if a `.context/` directory is found in `startDir`
|
|
24
|
-
* or an ancestor (stopping at the home directory / filesystem root), else null.
|
|
25
|
-
*/
|
|
26
|
-
export function findWorkspaceAppDevSessionsFile(startDir = process.cwd()) {
|
|
27
|
-
let dir = startDir;
|
|
28
|
-
const home = homedir();
|
|
29
|
-
const { root } = parsePath(dir);
|
|
30
|
-
// Bounded walk; `.context` lives at the workspace root, never above $HOME.
|
|
31
|
-
for (let depth = 0; depth < 64; depth += 1) {
|
|
32
|
-
if (existsSync(join(dir, '.context'))) {
|
|
33
|
-
return join(dir, '.context', 'app-dev-sessions.json');
|
|
34
|
-
}
|
|
35
|
-
if (dir === home || dir === root) break;
|
|
36
|
-
const parent = dirname(dir);
|
|
37
|
-
if (parent === dir) break;
|
|
38
|
-
dir = parent;
|
|
39
|
-
}
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
11
|
export function getAppDevSessionsFile(filePath) {
|
|
44
12
|
if (filePath) {
|
|
45
13
|
return filePath;
|
|
@@ -48,10 +16,6 @@ export function getAppDevSessionsFile(filePath) {
|
|
|
48
16
|
if (typeof envPath === 'string' && envPath.trim()) {
|
|
49
17
|
return envPath.trim();
|
|
50
18
|
}
|
|
51
|
-
const workspaceFile = findWorkspaceAppDevSessionsFile();
|
|
52
|
-
if (workspaceFile) {
|
|
53
|
-
return workspaceFile;
|
|
54
|
-
}
|
|
55
19
|
return DEFAULT_APP_DEV_SESSIONS_FILE;
|
|
56
20
|
}
|
|
57
21
|
|
|
@@ -173,7 +137,7 @@ function normalizeMountAcknowledgements(raw) {
|
|
|
173
137
|
typeof acknowledgement.devSlug === 'string' &&
|
|
174
138
|
typeof acknowledgement.mountNonce === 'string' &&
|
|
175
139
|
typeof acknowledgement.acknowledgedAt === 'string' &&
|
|
176
|
-
|
|
140
|
+
['listed', 'rendered'].includes(acknowledgement.stage),
|
|
177
141
|
);
|
|
178
142
|
}
|
|
179
143
|
|
|
@@ -218,12 +182,13 @@ export function removeAppDevSessionMountAcknowledgements(sessionId, sessionsFile
|
|
|
218
182
|
return writeAppDevSessionMountAcknowledgements(registry, sessionsFilePath);
|
|
219
183
|
}
|
|
220
184
|
|
|
221
|
-
function mountAcknowledgementKey(value) {
|
|
222
|
-
return `${value.sessionId}:${value.appId}:${value.devSlug}:${value.mountNonce}`;
|
|
185
|
+
function mountAcknowledgementKey(value, fallbackStage = 'listed') {
|
|
186
|
+
return `${value.sessionId}:${value.appId}:${value.devSlug}:${value.mountNonce}:${value.stage || fallbackStage}`;
|
|
223
187
|
}
|
|
224
188
|
|
|
225
189
|
export async function waitForAppDevSessionMountAcknowledgements(expectedSessions, options = {}) {
|
|
226
190
|
const expected = Array.isArray(expectedSessions) ? expectedSessions : [expectedSessions];
|
|
191
|
+
const stage = options.stage === 'rendered' ? 'rendered' : 'listed';
|
|
227
192
|
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, options.timeoutMs) : 15_000;
|
|
228
193
|
const pollIntervalMs = Number.isFinite(options.pollIntervalMs)
|
|
229
194
|
? Math.max(1, options.pollIntervalMs)
|
|
@@ -237,7 +202,9 @@ export async function waitForAppDevSessionMountAcknowledgements(expectedSessions
|
|
|
237
202
|
while (true) {
|
|
238
203
|
const registry = readAppDevSessionMountAcknowledgements(options.sessionsFilePath);
|
|
239
204
|
const acknowledgedKeys = new Set(registry.acknowledgements.map(mountAcknowledgementKey));
|
|
240
|
-
const missing = expected.filter(
|
|
205
|
+
const missing = expected.filter(
|
|
206
|
+
(session) => !acknowledgedKeys.has(mountAcknowledgementKey(session, stage)),
|
|
207
|
+
);
|
|
241
208
|
if (missing.length === 0) {
|
|
242
209
|
return { mounted: true, missing: [], acknowledgements: registry.acknowledgements };
|
|
243
210
|
}
|
|
@@ -249,3 +216,10 @@ export async function waitForAppDevSessionMountAcknowledgements(expectedSessions
|
|
|
249
216
|
await sleep(Math.min(pollIntervalMs, timeoutMs - elapsedMs));
|
|
250
217
|
}
|
|
251
218
|
}
|
|
219
|
+
|
|
220
|
+
export function waitForAppDevSessionRenderAcknowledgements(expectedSessions, options = {}) {
|
|
221
|
+
return waitForAppDevSessionMountAcknowledgements(expectedSessions, {
|
|
222
|
+
...options,
|
|
223
|
+
stage: 'rendered',
|
|
224
|
+
});
|
|
225
|
+
}
|
package/src/runtime/profiles.js
CHANGED
|
@@ -143,6 +143,14 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
|
143
143
|
const runtime = readJsonFile(runtimePath);
|
|
144
144
|
const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
|
|
145
145
|
const configFile = typeof runtime?.config_file === 'string' ? runtime.config_file : '';
|
|
146
|
+
const appDevSessionsFile =
|
|
147
|
+
typeof runtime?.app_dev_sessions_file === 'string' && runtime.app_dev_sessions_file.trim()
|
|
148
|
+
? runtime.app_dev_sessions_file.trim()
|
|
149
|
+
: join(dirname(runtimePath), 'app-dev-sessions.json');
|
|
150
|
+
const desktopDeepLinkScheme =
|
|
151
|
+
typeof runtime?.desktop_deep_link_scheme === 'string'
|
|
152
|
+
? runtime.desktop_deep_link_scheme.trim()
|
|
153
|
+
: '';
|
|
146
154
|
const pid = Number(runtime?.dev_pid);
|
|
147
155
|
if (
|
|
148
156
|
runtime?.mode !== 'local-only' ||
|
|
@@ -165,6 +173,8 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
|
165
173
|
...runtime,
|
|
166
174
|
api_base: apiBase,
|
|
167
175
|
config_file: resolve(dirname(runtimePath), configFile),
|
|
176
|
+
app_dev_sessions_file: resolve(dirname(runtimePath), appDevSessionsFile),
|
|
177
|
+
desktop_deep_link_scheme: desktopDeepLinkScheme || undefined,
|
|
168
178
|
runtime_path: runtimePath,
|
|
169
179
|
routing_path: routingPath,
|
|
170
180
|
};
|