@notis_ai/cli 0.2.0-beta.145.1 → 0.2.0-beta.151.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/README.md +2 -2
- package/dist/agent-hooks/notis-agent-hook.mjs +542 -146
- package/dist/base-skills/notis-apps/SKILL.md +12 -9
- package/package.json +1 -1
- package/skills/notis-apps/cli.md +2 -2
- package/src/command-specs/apps.js +118 -18
- package/src/runtime/app-dev-process-identity.js +111 -0
- package/src/runtime/app-dev-server.js +236 -8
- package/src/runtime/app-platform.js +23 -1
- package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
- package/template/packages/sdk/src/config.ts +6 -0
- package/template/packages/sdk/src/documents.ts +7 -1
- package/template/packages/sdk/src/hooks/useNotis.ts +3 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +7 -4
- package/template/packages/sdk/src/runtime.ts +7 -1
|
@@ -102,11 +102,11 @@ App code never accesses the runtime directly -- it uses SDK hooks (`useTool`, `u
|
|
|
102
102
|
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, scoped under the authenticated environment. Never pass a runtime app whose manifest has `is_dev: true` to `notis apps link`.
|
|
103
103
|
18. **Automatic mounts are multi-instance and least-authority** -- Prod, Beta, and source-development Desktop instances may mount the same source simultaneously with independent authenticated runtimes. Automatic mounting never grants capabilities: reuse existing grants and leave restricted capabilities denied until approved. Consumer leases expire after crashes so the shared host exits after the last live instance. There are no offline rows or manual start/stop controls.
|
|
104
104
|
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.
|
|
105
|
-
20. **Bump `notisAppVersion`
|
|
105
|
+
20. **Bump `notisAppVersion` before linked development and every Store update** -- `package.json` must contain a semver `notisAppVersion`. A linked local source substitutes its installed Workspace app only when the local version is strictly greater than the installed manifest's `release_version`; equal, lower, missing, or invalid versions keep serving the online bundle. `apps pull` retrieves the online version, so increment `notisAppVersion` before `apps dev` when continuing development. For an existing Store app, also increment it beyond the currently published registry version before deploy and submission; registry CI rejects equal or lower versions.
|
|
106
106
|
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.
|
|
107
107
|
22. **Database rows are private unless explicitly seeded** -- A string declaration such as `databases: ['notes']` publishes schema only and never includes the developer's rows. Use `{ slug: 'templates', seedDocuments: true }` only for small, intentional starter content that every installer should receive. Never enable it for user-created notes, history, leads, or other personal data.
|
|
108
108
|
23. **Public submissions are complete, reviewable packages** -- The registry PR must contain the full editable source tree, Store assets, exact source-declared database schemas, and only explicitly seeded starter rows. Registry CI validates those boundaries before merge; do not hand-edit `notis-listing.json` or strip source files to make a check pass. Fix the app locally, redeploy, and resubmit.
|
|
109
|
-
24. **New projects
|
|
109
|
+
24. **New projects default to `~/.notis/apps/<slug>`, and `[dir]` overrides it** -- `apps init` and `apps pull` use this stable, predictable home unless the app belongs in a specific repository, monorepo, or user-chosen location. In those cases, pass `[dir]` and report the resulting path. Do not nest an app inside a directory whose local workspace metadata selects an unrelated Notis runtime or profile: later CLI calls inherit that routing and may target the wrong environment.
|
|
110
110
|
|
|
111
111
|
## Anti-patterns -- NEVER do these
|
|
112
112
|
|
|
@@ -130,10 +130,10 @@ These are the most common mistakes agents make. Each one wastes time and produce
|
|
|
130
130
|
**Default to the Store scaffold catalog, not a blank project.** Every published Store app is a scaffold: `notis apps scaffolds list` reads the catalog from the public registry, and `notis apps init --from <slug>` downloads that app's source. Most user requests overlap with a published app, and starting from one is faster than a bare app.
|
|
131
131
|
|
|
132
132
|
1. **Find a starting point.** Run `npx --package @notis_ai/cli@latest -- notis apps scaffolds list` (add `--search <term>` to filter) to list the published Store apps. If something close matches, run `npx --package @notis_ai/cli@latest -- notis apps init "My App" --from <slug>` to download that app's source from the registry. Only run plain `notis apps init "My App"` when no published app fits. Either way the project lands in `~/.notis/apps/<slug>`; add a `[dir]` argument when the user wants it somewhere else (a tracked git repo, an existing monorepo), and report the path you used.
|
|
133
|
-
2. **Pull your own apps; fork Store apps with `--from`.** `apps pull` is for apps the user already has installed or deployed: run `npx --package @notis_ai/cli@latest -- notis apps list`, then `npx --package @notis_ai/cli@latest -- notis apps pull <app-id>` (lands in `~/.notis/apps/<app-slug>`; pass a `[dir]` argument to place it elsewhere). To fork a published Store app, use `apps init --from <slug>` instead -- it downloads the source from the registry and does not require installing the app first.
|
|
133
|
+
2. **Pull your own apps; fork Store apps with `--from`.** `apps pull` is for apps the user already has installed or deployed: run `npx --package @notis_ai/cli@latest -- notis apps list`, preserve any local edits in the target directory, then run `npx --package @notis_ai/cli@latest -- notis apps pull <app-id>` (lands in `~/.notis/apps/<app-slug>`; pass a `[dir]` argument to place it elsewhere). A pull reproduces the installed release, so increment `package.json` `notisAppVersion` above that release before `apps dev`; until then the online bundle remains active. To fork a published Store app, use `apps init --from <slug>` instead -- it downloads the source from the registry and does not require installing the app first.
|
|
134
134
|
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.
|
|
135
135
|
4. **Build pages in `app/`.** Reuse scaffold code wherever it fits.
|
|
136
|
-
5. **Iterate live.**
|
|
136
|
+
5. **Iterate live.** Before developing a linked installed app, increment `package.json` `notisAppVersion` above the installed release. Then run `npx --package @notis_ai/cli@latest -- notis apps dev [folder]` once. It permanently registers the root; `~/.notis/apps` is already implicit. Running Desktop instances discover the root itself, direct app children, and `apps/*`, then mount each successful build automatically in Workspace. A linked build substitutes the online bundle only while its version is strictly greater; equal or stale builds remain served online. Source edits hot-reload only that app in every instance. Add `--scratch` for isolated empty resources when the work needs fixtures or destructive experiments.
|
|
137
137
|
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. A `scenario` names an entry in `metadata/screenshot-fixtures.json`; besides `actions` it may carry its own `tools` and `requests`, shallow-merged per key over the file-level ones for that capture, which is how the same route is shown both populated and in its first-run empty state.
|
|
138
138
|
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. Incomplete listing media is only a `Store readiness:` warning there; run `notis apps verify --listing` before publish to make it a failure.
|
|
139
139
|
8. **Local-development-first handoff — STOP HERE.** Hand off after the user can see and test the app in its DEV-badged Workspace row. Building a new app to this point, without deploying, is a **complete and expected** result. Do NOT proceed to `apps create` / `apps deploy` yet. **Before handing off, complete all three acceptance checks:**
|
|
@@ -191,6 +191,7 @@ npx --package @notis_ai/cli@latest -- notis apps list
|
|
|
191
191
|
npx --package @notis_ai/cli@latest -- notis apps pull <installed-app-id>
|
|
192
192
|
cd ~/.notis/apps/my-app
|
|
193
193
|
npm install
|
|
194
|
+
# Increment package.json notisAppVersion above the pulled online release.
|
|
194
195
|
npx --package @notis_ai/cli@latest -- notis apps dev
|
|
195
196
|
npx --package @notis_ai/cli@latest -- notis apps build
|
|
196
197
|
npx --package @notis_ai/cli@latest -- notis apps verify
|
|
@@ -234,6 +235,8 @@ routes: [
|
|
|
234
235
|
|
|
235
236
|
Use the same page template for the root Notes route and collection/sub-collection detail states. The portal sidebar injects live collection items under the static route row when `collection.sidebar.mode === 'tree'`.
|
|
236
237
|
|
|
238
|
+
For arbitrary app-owned resources that are not Notis collection rows, set `resourceDeepLinks: true` on the route. Read the decoded `?resource=` identifier from `useNotis().resourceId`, and link between routes with `toRoute('/inbox', { resourceId })`. Keep collection links on `?item=`. Publish external preview/source links as the resource `url`; the host separately supplies the exact Notis review link as `active_resource.view_url` for opted-in routes. Handle missing or deleted identifiers with a safe view-level fallback.
|
|
239
|
+
|
|
237
240
|
### Step 2: Build pages
|
|
238
241
|
|
|
239
242
|
Standard React pages in `app/`. Use generic SDK tool hooks for data and build on top of the scaffolded shadcn components and portal shell classes (`notis-app-shell`, `notis-app-surface`):
|
|
@@ -444,10 +447,10 @@ already installs `ShortcutProvider`; app code should not add a second provider.
|
|
|
444
447
|
|
|
445
448
|
| API | Signature | Description |
|
|
446
449
|
|-----|-----------|-------------|
|
|
447
|
-
| `useNotis()` | `() => { app, route,
|
|
450
|
+
| `useNotis()` | `() => { app, route, databases, collectionItem, resourceId, ready }` | App metadata, current route, selected collection item, decoded exact-resource id, ready state |
|
|
448
451
|
| `useTool<TArgs, TResult>(name)` | `(name: string) => { call, loading, error }` | Call a declared tool with app-defined argument/result types. Identical idempotent reads may use `call(args, { dedupe: true })`; never dedupe writes |
|
|
449
452
|
| `useTools()` | `() => { tools, loading }` | List available tools |
|
|
450
|
-
| `useNotisNavigation()` | `() => { toRoute, toDocument, toApp }` | Navigate between routes, documents, or the app root |
|
|
453
|
+
| `useNotisNavigation()` | `() => { toRoute, toDocument, toApp }` | Navigate between routes (including `toRoute(path, { resourceId })`), documents, or the app root |
|
|
451
454
|
| `useTopBarSearch(opts)` | `({ value, onChange, placeholder?, onSubmit? }) => { setLoading }` | Bind the current view to the Portal-owned top-bar search input |
|
|
452
455
|
| `useBackend()` | `() => { request }` | Raw backend request proxy with JWT auth |
|
|
453
456
|
| `useDatabaseSubscription(slug, opts?)` | `(slug: string, opts?) => { rows, documents, loading, error, refetch, live }` | Query a database and refetch it when its rows change. `live` is false on hosts without a change feed (dev harness, vite preview) -- keep a manual refresh for those |
|
|
@@ -509,13 +512,13 @@ const result = await queryTasks.call({ database_id: 'tasks-db-id', query: { page
|
|
|
509
512
|
npx --package @notis_ai/cli@latest -- notis apps dev
|
|
510
513
|
```
|
|
511
514
|
|
|
512
|
-
Runs the real desktop-local development workflow. The CLI should discover all apps in the target workspace
|
|
515
|
+
Runs the real desktop-local development workflow. The CLI should discover all apps in the target workspace and serve their bundles from loopback. Unpublished apps appear in the Electron Portal's Workspace group; linked apps substitute their installed entry only when the local `notisAppVersion` is strictly greater than the installed `release_version`.
|
|
513
516
|
|
|
514
517
|
## Testing
|
|
515
518
|
|
|
516
519
|
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.
|
|
517
520
|
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.
|
|
518
|
-
3. **Local development acceptance**: Run `notis apps dev [folder]` once to register the root, then verify each signed-in Desktop instance independently
|
|
521
|
+
3. **Local development acceptance**: Run `notis apps dev [folder]` once to register the root, then verify each signed-in Desktop instance independently. For an unpublished app, expect one DEV-badged Workspace row. For a linked app, first confirm local `notisAppVersion` is strictly greater than installed `release_version`, then expect one substituted DEV-badged row; equal or lower must keep the online row and bundle. Verify the default route renders and live edits appear without restarting the CLI or Desktop. Use `notis apps roots list` as the persistence proof. Loopback bundle health alone does not prove that an authenticated instance mounted or rendered the app.
|
|
519
522
|
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.
|
|
520
523
|
|
|
521
524
|
### Headless harness verification
|
|
@@ -544,6 +547,6 @@ Run `npx --package @notis_ai/cli@latest -- notis apps verify` after `npx --packa
|
|
|
544
547
|
authenticated API available. Do not bypass DEV-app promotion or installed-app
|
|
545
548
|
identity with a direct database/storage write.
|
|
546
549
|
- **App shows old code after deploy**: Bundle cache is stale. Hard refresh (Cmd+Shift+R) or clear site data in DevTools.
|
|
547
|
-
- **App is missing from the sidebar**: Run `apps roots list`, confirm the app is at the root, one direct child, or `apps/*`, and confirm its first build succeeds. Restarting Desktop reattaches the same persistent roots; no terminal process or manual sidebar action is required.
|
|
550
|
+
- **App is missing from the sidebar**: Run `apps roots list`, confirm the app is at the root, one direct child, or `apps/*`, and confirm its first build succeeds. For a linked app, compare `package.json` `notisAppVersion` with the installed manifest's `release_version`: equal, lower, missing, or invalid intentionally keeps the online app without a DEV badge. If source is stale, preserve any local edits, run `apps pull <app-id> <dir> --force` to refresh it, then increment `notisAppVersion` before continuing development. Restarting Desktop reattaches the same persistent roots; no terminal process or manual sidebar action is required.
|
|
548
551
|
- **`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.
|
|
549
552
|
- **Properties are `undefined`**: Keep app-local result types for `useTool<TArgs, TResult>` and guard optional nested properties when reading live data.
|
package/package.json
CHANGED
package/skills/notis-apps/cli.md
CHANGED
|
@@ -118,7 +118,7 @@ Examples:
|
|
|
118
118
|
|
|
119
119
|
Register a development root and connect its apps to the shared local development host.
|
|
120
120
|
|
|
121
|
-
When to use: Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance.
|
|
121
|
+
When to use: Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.
|
|
122
122
|
|
|
123
123
|
Options:
|
|
124
124
|
- `--port <number>` — Local bundle server port (default: 5173).
|
|
@@ -218,7 +218,7 @@ Examples:
|
|
|
218
218
|
|
|
219
219
|
Download a Notis app source snapshot into a local project folder.
|
|
220
220
|
|
|
221
|
-
When to use: Edit an installed app locally.
|
|
221
|
+
When to use: Edit an installed app locally. Preserve any local edits, pull and link the latest persisted source, then increment package.json notisAppVersion above that release before notis apps dev; continue with build and deploy.
|
|
222
222
|
|
|
223
223
|
Options:
|
|
224
224
|
- `--force` — Overwrite a non-empty target directory.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
11
|
+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
14
14
|
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
normalizeAppCapabilities,
|
|
37
37
|
normalizeAppToolBindings,
|
|
38
38
|
normalizeAppSkillManifestPath,
|
|
39
|
+
appRowFieldsFromManifest,
|
|
39
40
|
directDeploy,
|
|
40
41
|
pullAppSource,
|
|
41
42
|
} from '../runtime/app-platform.js';
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
scaffoldRegistryLabel,
|
|
46
47
|
} from '../runtime/app-registry-scaffolds.js';
|
|
47
48
|
import { startAppDevServer } from '../runtime/app-dev-server.js';
|
|
49
|
+
import { captureDesktopHostOwnership } from '../runtime/app-dev-process-identity.js';
|
|
48
50
|
import {
|
|
49
51
|
discoverRegisteredAppProjects,
|
|
50
52
|
readAppDevRoots,
|
|
@@ -87,6 +89,8 @@ import {
|
|
|
87
89
|
toolConflictToError,
|
|
88
90
|
} from './helpers.js';
|
|
89
91
|
|
|
92
|
+
export { appRowFieldsFromManifest } from '../runtime/app-platform.js';
|
|
93
|
+
|
|
90
94
|
const DEFAULT_DEV_PORT = 5173;
|
|
91
95
|
const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
92
96
|
const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
|
|
@@ -98,6 +102,72 @@ function projectIsWithinRoot(projectDir, rootDir) {
|
|
|
98
102
|
return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
function parseNotisAppVersion(value) {
|
|
106
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
|
107
|
+
String(value || '').trim(),
|
|
108
|
+
);
|
|
109
|
+
if (!match) return null;
|
|
110
|
+
const prerelease = match[4] ? match[4].split('.') : [];
|
|
111
|
+
if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
major: match[1],
|
|
116
|
+
minor: match[2],
|
|
117
|
+
patch: match[3],
|
|
118
|
+
prerelease,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function compareNumericSemverIdentifiers(left, right) {
|
|
123
|
+
if (left.length !== right.length) return left.length > right.length ? 1 : -1;
|
|
124
|
+
return left === right ? 0 : left > right ? 1 : -1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function compareNotisPrerelease(left, right) {
|
|
128
|
+
if (left.length === 0 || right.length === 0) {
|
|
129
|
+
return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
130
|
+
}
|
|
131
|
+
const length = Math.max(left.length, right.length);
|
|
132
|
+
for (let index = 0; index < length; index += 1) {
|
|
133
|
+
const leftIdentifier = left[index];
|
|
134
|
+
const rightIdentifier = right[index];
|
|
135
|
+
if (leftIdentifier === undefined || rightIdentifier === undefined) {
|
|
136
|
+
return leftIdentifier === rightIdentifier ? 0 : leftIdentifier === undefined ? -1 : 1;
|
|
137
|
+
}
|
|
138
|
+
if (leftIdentifier === rightIdentifier) continue;
|
|
139
|
+
const leftNumeric = /^\d+$/.test(leftIdentifier);
|
|
140
|
+
const rightNumeric = /^\d+$/.test(rightIdentifier);
|
|
141
|
+
if (leftNumeric && rightNumeric) {
|
|
142
|
+
return compareNumericSemverIdentifiers(leftIdentifier, rightIdentifier);
|
|
143
|
+
}
|
|
144
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
145
|
+
return leftIdentifier > rightIdentifier ? 1 : -1;
|
|
146
|
+
}
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function compareNotisAppVersions(leftValue, rightValue) {
|
|
151
|
+
const left = parseNotisAppVersion(leftValue);
|
|
152
|
+
const right = parseNotisAppVersion(rightValue);
|
|
153
|
+
if (!left || !right) return null;
|
|
154
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
155
|
+
const comparison = compareNumericSemverIdentifiers(left[key], right[key]);
|
|
156
|
+
if (comparison !== 0) return comparison;
|
|
157
|
+
}
|
|
158
|
+
return compareNotisPrerelease(left.prerelease, right.prerelease);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readLocalNotisAppVersion(projectDir) {
|
|
162
|
+
try {
|
|
163
|
+
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
|
164
|
+
const version = String(packageJson.notisAppVersion || '').trim();
|
|
165
|
+
return parseNotisAppVersion(version) ? version : null;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
101
171
|
/**
|
|
102
172
|
* A CLI launch may add one root, but the shared host always serves the complete
|
|
103
173
|
* machine registry. Desktop sets skipRootRegistration because it is reconciling
|
|
@@ -410,13 +480,6 @@ export function screenshotIndexByRouteSlug(manifest) {
|
|
|
410
480
|
return new Map(routes.map((route, index) => [route.slug, index + 1]));
|
|
411
481
|
}
|
|
412
482
|
|
|
413
|
-
export function appRowFieldsFromManifest(manifest) {
|
|
414
|
-
const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
|
|
415
|
-
return {
|
|
416
|
-
accent: app.accent ?? null,
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
|
|
420
483
|
export function screenshotExitCode(failedCount) {
|
|
421
484
|
return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
|
|
422
485
|
}
|
|
@@ -570,7 +633,7 @@ function renderVerifyReport({ summary, results, noBrowser }) {
|
|
|
570
633
|
return lines.join('\n');
|
|
571
634
|
}
|
|
572
635
|
|
|
573
|
-
function buildManifestForDev(appConfig) {
|
|
636
|
+
function buildManifestForDev(appConfig, projectDir) {
|
|
574
637
|
const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
575
638
|
return {
|
|
576
639
|
version: 1,
|
|
@@ -579,6 +642,7 @@ function buildManifestForDev(appConfig) {
|
|
|
579
642
|
name: appConfig.name,
|
|
580
643
|
description: appConfig.description || null,
|
|
581
644
|
icon: appConfig.icon || null,
|
|
645
|
+
release_version: readLocalNotisAppVersion(projectDir),
|
|
582
646
|
},
|
|
583
647
|
routes: routes.map((route) => ({
|
|
584
648
|
path: route.path,
|
|
@@ -587,6 +651,7 @@ function buildManifestForDev(appConfig) {
|
|
|
587
651
|
icon: route.icon || null,
|
|
588
652
|
parentSlug: route.parentSlug || null,
|
|
589
653
|
default: route.default || false,
|
|
654
|
+
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
590
655
|
export_name: route.exportName || route.export_name,
|
|
591
656
|
collection: route.collection || null,
|
|
592
657
|
})),
|
|
@@ -726,7 +791,7 @@ export async function ensureDevInstall({
|
|
|
726
791
|
throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
|
|
727
792
|
}
|
|
728
793
|
|
|
729
|
-
const manifest = buildManifestForDev(appConfig);
|
|
794
|
+
const manifest = buildManifestForDev(appConfig, projectDir);
|
|
730
795
|
const skills = resolveConfiguredAppSkills(appConfig, projectDir);
|
|
731
796
|
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
732
797
|
let linkedState = readLinkedState(projectDir, profileKey);
|
|
@@ -801,6 +866,10 @@ export async function ensureDevInstall({
|
|
|
801
866
|
].includes(key)),
|
|
802
867
|
)
|
|
803
868
|
: linkedState;
|
|
869
|
+
const localReleaseVersion = manifest.app.release_version || null;
|
|
870
|
+
const installedReleaseVersion = linkedApp?.manifest?.app?.release_version || '0.0.0';
|
|
871
|
+
const mountEligible = !linkedApp
|
|
872
|
+
|| compareNotisAppVersions(localReleaseVersion, installedReleaseVersion) === 1;
|
|
804
873
|
const ensureArguments = buildEnsureDevInstallArguments({
|
|
805
874
|
appConfig,
|
|
806
875
|
manifest,
|
|
@@ -866,6 +935,9 @@ export async function ensureDevInstall({
|
|
|
866
935
|
linkedAppId: runtimeLinkedState?.app_id || null,
|
|
867
936
|
targetAppId: runtimeLinkedState?.app_id || null,
|
|
868
937
|
targetAppSlug: linkedApp?.slug || null,
|
|
938
|
+
localReleaseVersion,
|
|
939
|
+
installedReleaseVersion: linkedApp ? installedReleaseVersion : null,
|
|
940
|
+
mountEligible,
|
|
869
941
|
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
870
942
|
liveData: ensureResult.payload.live_data || null,
|
|
871
943
|
};
|
|
@@ -892,6 +964,16 @@ function liveDataWarnings(apps) {
|
|
|
892
964
|
.map((app) => `${app.name}: ${app.liveData.warning}`);
|
|
893
965
|
}
|
|
894
966
|
|
|
967
|
+
function versionPrecedenceWarnings(apps) {
|
|
968
|
+
return apps
|
|
969
|
+
.filter((app) => app.targetAppId && app.mountEligible === false)
|
|
970
|
+
.map((app) => {
|
|
971
|
+
const localVersion = app.localReleaseVersion || 'missing or invalid';
|
|
972
|
+
const pullCommand = `npx --package @notis_ai/cli@latest -- notis apps pull ${app.targetAppId} ${JSON.stringify(app.projectDir)} --force`;
|
|
973
|
+
return `${app.name}: local version ${localVersion} is not strictly newer than installed version ${app.installedReleaseVersion}; Workspace keeps serving the online bundle. Preserve any local edits, pull latest with \`${pullCommand}\`, then bump package.json notisAppVersion before development.`;
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
895
977
|
async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
896
978
|
const result = await runTool({
|
|
897
979
|
runtime,
|
|
@@ -1305,6 +1387,7 @@ async function appsDevHandler(ctx) {
|
|
|
1305
1387
|
isBundleReady: () => true,
|
|
1306
1388
|
updateApp: () => {},
|
|
1307
1389
|
waitForBundle: async () => {},
|
|
1390
|
+
getWatcherOwnership: () => null,
|
|
1308
1391
|
}
|
|
1309
1392
|
: await startAppDevServer({
|
|
1310
1393
|
apps: canonicalCandidates.map((app) => ({
|
|
@@ -1320,6 +1403,10 @@ async function appsDevHandler(ctx) {
|
|
|
1320
1403
|
port,
|
|
1321
1404
|
sessionsFilePath,
|
|
1322
1405
|
});
|
|
1406
|
+
const desktopHostOwnership = captureDesktopHostOwnership({
|
|
1407
|
+
desktopOwnerId: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_ID,
|
|
1408
|
+
desktopOwnerScope: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_SCOPE,
|
|
1409
|
+
});
|
|
1323
1410
|
|
|
1324
1411
|
let heartbeatTimer = setInterval(() => {
|
|
1325
1412
|
try {
|
|
@@ -1379,6 +1466,8 @@ async function appsDevHandler(ctx) {
|
|
|
1379
1466
|
sessionId,
|
|
1380
1467
|
hostPid: process.pid,
|
|
1381
1468
|
sourceHost: !sharedBundleBaseUrls,
|
|
1469
|
+
...(desktopHostOwnership || {}),
|
|
1470
|
+
...(devServer.getWatcherOwnership(app.devSlug) || {}),
|
|
1382
1471
|
bundleReady: devServer.isBundleReady(app.devSlug),
|
|
1383
1472
|
...(!sharedBundleBaseUrls ? {
|
|
1384
1473
|
discoveredProjects: discoveredAppDirs,
|
|
@@ -1456,6 +1545,7 @@ async function appsDevHandler(ctx) {
|
|
|
1456
1545
|
...registrationWarnings,
|
|
1457
1546
|
...databaseMaterializationWarnings(apps),
|
|
1458
1547
|
...liveDataWarnings(apps),
|
|
1548
|
+
...versionPrecedenceWarnings(apps),
|
|
1459
1549
|
];
|
|
1460
1550
|
|
|
1461
1551
|
let consumerTimer = null;
|
|
@@ -1481,6 +1571,9 @@ async function appsDevHandler(ctx) {
|
|
|
1481
1571
|
linked_app_id: app.linkedAppId,
|
|
1482
1572
|
database_materialization: app.databaseMaterialization,
|
|
1483
1573
|
live_data: app.liveData,
|
|
1574
|
+
local_release_version: app.localReleaseVersion,
|
|
1575
|
+
installed_release_version: app.installedReleaseVersion,
|
|
1576
|
+
mount_eligible: app.mountEligible,
|
|
1484
1577
|
})),
|
|
1485
1578
|
},
|
|
1486
1579
|
warnings,
|
|
@@ -1492,11 +1585,15 @@ async function appsDevHandler(ctx) {
|
|
|
1492
1585
|
? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
|
|
1493
1586
|
: []),
|
|
1494
1587
|
'',
|
|
1495
|
-
...apps.map((app) =>
|
|
1588
|
+
...apps.map((app) => (
|
|
1589
|
+
app.mountEligible
|
|
1590
|
+
? ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`
|
|
1591
|
+
: ` ${app.name.padEnd(24)} online v${app.installedReleaseVersion} (local ${app.localReleaseVersion || 'version missing'})`
|
|
1592
|
+
)),
|
|
1496
1593
|
'',
|
|
1497
1594
|
sharedBundleBaseUrls
|
|
1498
|
-
? `
|
|
1499
|
-
: `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}.`,
|
|
1595
|
+
? `Attached to the shared source host: ${apps.filter((app) => app.mountEligible).length}/${apps.length} app${apps.length === 1 ? '' : 's'} eligible to substitute.`
|
|
1596
|
+
: `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}; ${apps.filter((app) => app.mountEligible).length} eligible to substitute.`,
|
|
1500
1597
|
'',
|
|
1501
1598
|
'Press Ctrl-C to stop.',
|
|
1502
1599
|
].join('\n'),
|
|
@@ -1527,12 +1624,15 @@ async function appsDevHandler(ctx) {
|
|
|
1527
1624
|
}
|
|
1528
1625
|
}
|
|
1529
1626
|
try {
|
|
1530
|
-
|
|
1627
|
+
await devServer.close();
|
|
1531
1628
|
} catch {
|
|
1532
1629
|
// ignore cleanup failures during shutdown
|
|
1533
1630
|
}
|
|
1631
|
+
// Keep ownership records until every watcher group has stopped. If the
|
|
1632
|
+
// Desktop must force this host down, the next launch can still recover a
|
|
1633
|
+
// verified orphan instead of losing its only ownership proof.
|
|
1534
1634
|
try {
|
|
1535
|
-
|
|
1635
|
+
removeAppDevSession(sessionId, sessionsFilePath);
|
|
1536
1636
|
} catch {
|
|
1537
1637
|
// ignore cleanup failures during shutdown
|
|
1538
1638
|
}
|
|
@@ -2148,7 +2248,7 @@ async function appsPullHandler(ctx) {
|
|
|
2148
2248
|
project_dir: pulled.projectDir,
|
|
2149
2249
|
version: pulled.version,
|
|
2150
2250
|
},
|
|
2151
|
-
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}.
|
|
2251
|
+
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Increment package.json notisAppVersion above the pulled release, then run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to substitute the online bundle.`,
|
|
2152
2252
|
});
|
|
2153
2253
|
}
|
|
2154
2254
|
|
|
@@ -2599,7 +2699,7 @@ export const appsCommandSpecs = [
|
|
|
2599
2699
|
command_path: ['apps', 'dev'],
|
|
2600
2700
|
summary: 'Register a development root and connect its apps to the shared local development host.',
|
|
2601
2701
|
when_to_use:
|
|
2602
|
-
'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance.',
|
|
2702
|
+
'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.',
|
|
2603
2703
|
args_schema: {
|
|
2604
2704
|
arguments: [
|
|
2605
2705
|
{ token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
|
|
@@ -2775,7 +2875,7 @@ export const appsCommandSpecs = [
|
|
|
2775
2875
|
command_path: ['apps', 'pull'],
|
|
2776
2876
|
summary: 'Download a Notis app source snapshot into a local project folder.',
|
|
2777
2877
|
when_to_use:
|
|
2778
|
-
'Edit an installed app locally.
|
|
2878
|
+
'Edit an installed app locally. Preserve any local edits, pull and link the latest persisted source, then increment package.json notisAppVersion above that release before notis apps dev; continue with build and deploy.',
|
|
2779
2879
|
args_schema: {
|
|
2780
2880
|
arguments: [
|
|
2781
2881
|
{ token: '<app-id>', description: 'Remote app ID to pull.' },
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readlinkSync, realpathSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export const NOTIS_APP_BUILD_COMMAND_FINGERPRINT = 'npm:run-build:watch:v1';
|
|
5
|
+
export const NOTIS_APPS_DEV_HOST_COMMAND_FINGERPRINT = 'notis:apps-dev:v1';
|
|
6
|
+
|
|
7
|
+
function normalizePath(value) {
|
|
8
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
9
|
+
try {
|
|
10
|
+
return realpathSync(value.trim());
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isExpectedNotisBuildCommand(command) {
|
|
17
|
+
if (typeof command !== 'string') return false;
|
|
18
|
+
return /(?:^|[/\s])npm(?:\s|$)/.test(command)
|
|
19
|
+
&& /\brun\s+build\b/.test(command)
|
|
20
|
+
&& /(?:^|\s)--watch(?:\s|$)/.test(command);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isExpectedNotisAppsDevHostCommand(command) {
|
|
24
|
+
return typeof command === 'string'
|
|
25
|
+
&& /(?:notis(?:\.js)?|@notis_ai[/\\]cli)/.test(command)
|
|
26
|
+
&& /\bapps\s+dev\b/.test(command);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readDarwinProcessCwd(pid, execute) {
|
|
30
|
+
const output = execute('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
33
|
+
});
|
|
34
|
+
const line = output.split('\n').find((entry) => entry.startsWith('n'));
|
|
35
|
+
return line ? line.slice(1) : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function inspectAppDevWatcherProcess(pid, {
|
|
39
|
+
platform = process.platform,
|
|
40
|
+
execute = execFileSync,
|
|
41
|
+
readLink = readlinkSync,
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || platform === 'win32') return null;
|
|
44
|
+
try {
|
|
45
|
+
const ps = (field) => execute('ps', ['-o', `${field}=`, '-p', String(pid)], {
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
}).trim();
|
|
49
|
+
const processGroupPid = Number.parseInt(ps('pgid'), 10);
|
|
50
|
+
const startIdentity = ps('lstart').replace(/\s+/g, ' ').trim();
|
|
51
|
+
const command = ps('command');
|
|
52
|
+
const cwd = platform === 'linux'
|
|
53
|
+
? readLink(`/proc/${pid}/cwd`)
|
|
54
|
+
: readDarwinProcessCwd(pid, execute);
|
|
55
|
+
const projectDir = normalizePath(cwd);
|
|
56
|
+
if (!Number.isSafeInteger(processGroupPid) || processGroupPid <= 0) return null;
|
|
57
|
+
if (!startIdentity || !command || !projectDir) return null;
|
|
58
|
+
return { pid, processGroupPid, startIdentity, command, projectDir };
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function captureDesktopWatcherOwnership({
|
|
65
|
+
pid,
|
|
66
|
+
projectDir,
|
|
67
|
+
desktopOwnerId,
|
|
68
|
+
desktopOwnerScope,
|
|
69
|
+
inspect = inspectAppDevWatcherProcess,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const owner = typeof desktopOwnerId === 'string' ? desktopOwnerId.trim() : '';
|
|
72
|
+
const ownerScope = typeof desktopOwnerScope === 'string' ? desktopOwnerScope.trim() : '';
|
|
73
|
+
const expectedProjectDir = normalizePath(projectDir);
|
|
74
|
+
if (!owner || !ownerScope || !expectedProjectDir) return null;
|
|
75
|
+
const identity = inspect(pid);
|
|
76
|
+
if (
|
|
77
|
+
!identity
|
|
78
|
+
|| identity.processGroupPid !== pid
|
|
79
|
+
|| identity.projectDir !== expectedProjectDir
|
|
80
|
+
|| !isExpectedNotisBuildCommand(identity.command)
|
|
81
|
+
) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
desktopOwnerId: owner,
|
|
86
|
+
desktopOwnerScope: ownerScope,
|
|
87
|
+
watcherProcessGroupPid: identity.processGroupPid,
|
|
88
|
+
watcherStartIdentity: identity.startIdentity,
|
|
89
|
+
watcherProjectDir: identity.projectDir,
|
|
90
|
+
watcherCommandFingerprint: NOTIS_APP_BUILD_COMMAND_FINGERPRINT,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function captureDesktopHostOwnership({
|
|
95
|
+
pid = process.pid,
|
|
96
|
+
desktopOwnerId,
|
|
97
|
+
desktopOwnerScope,
|
|
98
|
+
inspect = inspectAppDevWatcherProcess,
|
|
99
|
+
} = {}) {
|
|
100
|
+
const owner = typeof desktopOwnerId === 'string' ? desktopOwnerId.trim() : '';
|
|
101
|
+
const ownerScope = typeof desktopOwnerScope === 'string' ? desktopOwnerScope.trim() : '';
|
|
102
|
+
if (!owner || !ownerScope) return null;
|
|
103
|
+
const identity = inspect(pid);
|
|
104
|
+
if (!identity || !isExpectedNotisAppsDevHostCommand(identity.command)) return null;
|
|
105
|
+
return {
|
|
106
|
+
desktopOwnerId: owner,
|
|
107
|
+
desktopOwnerScope: ownerScope,
|
|
108
|
+
desktopHostStartIdentity: identity.startIdentity,
|
|
109
|
+
desktopHostCommandFingerprint: NOTIS_APPS_DEV_HOST_COMMAND_FINGERPRINT,
|
|
110
|
+
};
|
|
111
|
+
}
|