@notis_ai/cli 0.2.13 → 0.2.15
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 +125 -21
- package/dist/agent-hooks/notis-agent-hook.mjs +19750 -0
- package/{skills → dist/base-skills}/notis-apps/SKILL.md +93 -58
- package/{skills → dist/base-skills}/notis-cli/SKILL.md +9 -9
- package/dist/base-skills/notis-query/SKILL.md +705 -0
- package/dist/skill-sync/index.js +1528 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/package.json +5 -2
- package/skills/notis-apps/cli.md +44 -18
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +16 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +23 -14
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1090 -418
- package/src/command-specs/auth.js +63 -10
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/index.js +9 -0
- package/src/command-specs/onboarding.js +69 -5
- package/src/command-specs/skills.js +56 -0
- package/src/command-specs/tools.js +6 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-dev-consumers.js +154 -0
- package/src/runtime/app-dev-host-lock.js +80 -0
- package/src/runtime/app-dev-roots.js +284 -0
- package/src/runtime/app-dev-server.js +229 -30
- package/src/runtime/app-dev-sessions.js +105 -145
- package/src/runtime/app-platform.js +1499 -161
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +1506 -141
- package/src/runtime/output.js +7 -0
- package/src/runtime/ports.js +16 -0
- package/src/runtime/profiles.js +0 -5
- package/src/runtime/skill-sync/cloud-client.ts +96 -0
- package/src/runtime/skill-sync/index.ts +644 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +383 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +103 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/store-screenshot.js +6 -1
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +215 -0
- package/src/runtime/transport.js +19 -2
- package/template/.harness/index.html.tmpl +116 -47
- package/template/app/layout.tsx +5 -1
- package/template/notis.config.ts +1 -0
- package/template/packages/sdk/package.json +2 -1
- package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/config.ts +71 -0
- package/template/packages/sdk/src/documents.ts +28 -1
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
- package/template/packages/sdk/src/hooks/useTool.ts +5 -4
- package/template/packages/sdk/src/index.ts +64 -2
- package/template/packages/sdk/src/interactions/actions.ts +46 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
- package/template/packages/sdk/src/interactions.ts +41 -0
- package/template/packages/sdk/src/provider.tsx +2 -1
- package/template/packages/sdk/src/runtime.ts +219 -3
- package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
- package/dist/scaffolds/notis-database/app/globals.css +0 -44
- package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
- package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
- package/dist/scaffolds/notis-database/components.json +0 -20
- package/dist/scaffolds/notis-database/index.html +0 -12
- package/dist/scaffolds/notis-database/lib/types.ts +0 -132
- package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
- package/dist/scaffolds/notis-database/notis.config.ts +0 -73
- package/dist/scaffolds/notis-database/package-lock.json +0 -3935
- package/dist/scaffolds/notis-database/package.json +0 -32
- package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -196
- package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -83
- package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -220
- package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
- package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
- package/dist/scaffolds/notis-database/tsconfig.json +0 -23
- package/dist/scaffolds/notis-database/vite.config.ts +0 -22
- package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
- package/dist/scaffolds/notis-journal/app/globals.css +0 -37
- package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
- package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
- package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
- package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
- package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-journal/components.json +0 -20
- package/dist/scaffolds/notis-journal/index.html +0 -12
- package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
- package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
- package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
- package/dist/scaffolds/notis-journal/package.json +0 -34
- package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -196
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -83
- package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -220
- package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
- package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -197
- package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
- package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
- package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
- package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
- package/dist/scaffolds/notis-notes/app/globals.css +0 -3
- package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
- package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
- package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-notes/components.json +0 -20
- package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
- package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
- package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
- package/dist/scaffolds/notis-notes/package.json +0 -35
- package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -196
- package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -83
- package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -220
- package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
- package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
- package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
- package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
- package/dist/scaffolds/notis-random/README.md +0 -33
- package/dist/scaffolds/notis-random/app/globals.css +0 -11
- package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
- package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
- package/dist/scaffolds/notis-random/app/page.tsx +0 -289
- package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
- package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
- package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
- package/dist/scaffolds/notis-random/components.json +0 -20
- package/dist/scaffolds/notis-random/index.html +0 -12
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
- package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
- package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
- package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
- package/dist/scaffolds/notis-random/notis.config.ts +0 -86
- package/dist/scaffolds/notis-random/package-lock.json +0 -4513
- package/dist/scaffolds/notis-random/package.json +0 -36
- package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -196
- package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -83
- package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -220
- package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
- package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
- package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
- package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
- package/dist/scaffolds/notis-random/tsconfig.json +0 -23
- package/dist/scaffolds/notis-random/vite.config.ts +0 -11
- package/dist/scaffolds.json +0 -49
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
|
@@ -8,35 +8,49 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import {
|
|
12
|
-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
11
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
13
12
|
import { tmpdir } from 'node:os';
|
|
14
|
-
import { basename, join, relative, resolve } from 'node:path';
|
|
13
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
15
14
|
|
|
16
|
-
import { EXIT_CODES, usageError } from '../runtime/errors.js';
|
|
15
|
+
import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
|
|
17
16
|
import { formatTable } from '../runtime/output.js';
|
|
18
17
|
import {
|
|
18
|
+
defaultAppProjectDir,
|
|
19
19
|
resolveProjectDir,
|
|
20
20
|
loadAppConfig,
|
|
21
21
|
detectProjectProblems,
|
|
22
22
|
detectProjectWarnings,
|
|
23
23
|
buildArtifact,
|
|
24
|
+
appLinkedStateProfileKey,
|
|
24
25
|
readManifest,
|
|
25
26
|
readLinkedState,
|
|
26
27
|
writeLinkedState,
|
|
27
28
|
requireLinkedAppId,
|
|
28
29
|
scaffoldProject,
|
|
29
|
-
|
|
30
|
+
findUnknownScreenshotScenarios,
|
|
30
31
|
inspectListingReadiness,
|
|
31
32
|
resolveListingScreenshots,
|
|
32
33
|
collectArtifactFiles,
|
|
33
34
|
collectSourceFiles,
|
|
34
35
|
resolveConfiguredAppSkills,
|
|
35
36
|
normalizeAppCapabilities,
|
|
37
|
+
normalizeAppToolBindings,
|
|
38
|
+
normalizeAppSkillManifestPath,
|
|
36
39
|
directDeploy,
|
|
37
40
|
pullAppSource,
|
|
38
41
|
} from '../runtime/app-platform.js';
|
|
42
|
+
import {
|
|
43
|
+
filterScaffoldCatalog,
|
|
44
|
+
loadScaffoldCatalog,
|
|
45
|
+
scaffoldRegistryLabel,
|
|
46
|
+
} from '../runtime/app-registry-scaffolds.js';
|
|
39
47
|
import { startAppDevServer } from '../runtime/app-dev-server.js';
|
|
48
|
+
import {
|
|
49
|
+
discoverRegisteredAppProjects,
|
|
50
|
+
readAppDevRoots,
|
|
51
|
+
registerAppDevRoot,
|
|
52
|
+
removeAppDevRoot,
|
|
53
|
+
} from '../runtime/app-dev-roots.js';
|
|
40
54
|
import {
|
|
41
55
|
captureHarnessScreenshot,
|
|
42
56
|
closeAgentBrowserSession,
|
|
@@ -46,12 +60,22 @@ import {
|
|
|
46
60
|
import {
|
|
47
61
|
getAppDevSessionsFile,
|
|
48
62
|
heartbeatAppDevSession,
|
|
63
|
+
linkAppDevSessionTarget,
|
|
64
|
+
readAppDevSessions,
|
|
49
65
|
removeAppDevSession,
|
|
50
66
|
upsertAppDevSessions,
|
|
51
|
-
waitForAppDevSessionMountAcknowledgements,
|
|
52
|
-
waitForAppDevSessionRenderAcknowledgements,
|
|
53
67
|
} from '../runtime/app-dev-sessions.js';
|
|
54
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
releaseAppDevHostLock,
|
|
70
|
+
tryAcquireAppDevHostLock,
|
|
71
|
+
} from '../runtime/app-dev-host-lock.js';
|
|
72
|
+
import {
|
|
73
|
+
heartbeatAppDevConsumer,
|
|
74
|
+
hasAppDevConsumer,
|
|
75
|
+
readAppDevConsumers,
|
|
76
|
+
removeAppDevConsumer,
|
|
77
|
+
} from '../runtime/app-dev-consumers.js';
|
|
78
|
+
import { getAvailablePort, getAvailablePortPreferring } from '../runtime/ports.js';
|
|
55
79
|
import { getCliMode } from '../runtime/cli-mode.js';
|
|
56
80
|
import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
|
|
57
81
|
import { httpRequest } from '../runtime/transport.js';
|
|
@@ -65,13 +89,142 @@ import {
|
|
|
65
89
|
|
|
66
90
|
const DEFAULT_DEV_PORT = 5173;
|
|
67
91
|
const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
68
|
-
const
|
|
92
|
+
const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
|
|
93
|
+
const DEV_CONSUMER_POLL_INTERVAL_MS = 5_000;
|
|
94
|
+
const SHARED_APP_DEV_HOST_KEY = '__registered_roots__';
|
|
95
|
+
|
|
96
|
+
function projectIsWithinRoot(projectDir, rootDir) {
|
|
97
|
+
const nested = relative(rootDir, projectDir);
|
|
98
|
+
return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A CLI launch may add one root, but the shared host always serves the complete
|
|
103
|
+
* machine registry. Desktop sets skipRootRegistration because it is reconciling
|
|
104
|
+
* an already-registered snapshot; this must never narrow discovery to the one
|
|
105
|
+
* project path used to start the host.
|
|
106
|
+
*/
|
|
107
|
+
export function discoverAppDevLaunchProjects(rootDir, {
|
|
108
|
+
skipRootRegistration = false,
|
|
109
|
+
registerRoot = registerAppDevRoot,
|
|
110
|
+
discoverProjects = discoverRegisteredAppProjects,
|
|
111
|
+
} = {}) {
|
|
112
|
+
if (!skipRootRegistration) registerRoot(rootDir);
|
|
113
|
+
return discoverProjects();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Collapse duplicate local sources before they reach the shared loopback host.
|
|
118
|
+
* An explicitly registered root is more intentional than the implicit
|
|
119
|
+
* ~/.notis/apps root. If two equally intentional roots claim the same dev
|
|
120
|
+
* slug, omit that slug and keep serving every unrelated app.
|
|
121
|
+
*/
|
|
122
|
+
export function selectCanonicalDevApps(candidates, rootsRegistry) {
|
|
123
|
+
const roots = Array.isArray(rootsRegistry?.roots) ? rootsRegistry.roots : [];
|
|
124
|
+
const groups = new Map();
|
|
125
|
+
for (const candidate of candidates) {
|
|
126
|
+
groups.set(candidate.devSlug, [...(groups.get(candidate.devSlug) || []), candidate]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const selected = [];
|
|
130
|
+
const warnings = [];
|
|
131
|
+
for (const [devSlug, group] of groups) {
|
|
132
|
+
if (group.length === 1) {
|
|
133
|
+
selected.push(group[0]);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const ranked = group.map((candidate) => {
|
|
137
|
+
const explicitRoots = roots.filter((root) => (
|
|
138
|
+
root?.implicit !== true
|
|
139
|
+
&& typeof root?.path === 'string'
|
|
140
|
+
&& projectIsWithinRoot(candidate.projectDir, root.path)
|
|
141
|
+
));
|
|
142
|
+
const newestRegistration = explicitRoots.reduce((latest, root) => {
|
|
143
|
+
const timestamp = Date.parse(root.registeredAt || '');
|
|
144
|
+
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
|
|
145
|
+
}, 0);
|
|
146
|
+
return {
|
|
147
|
+
candidate,
|
|
148
|
+
rank: explicitRoots.length > 0 ? 1 : 0,
|
|
149
|
+
newestRegistration,
|
|
150
|
+
};
|
|
151
|
+
}).sort((left, right) => (
|
|
152
|
+
right.rank - left.rank
|
|
153
|
+
|| right.newestRegistration - left.newestRegistration
|
|
154
|
+
));
|
|
155
|
+
const winner = ranked[0];
|
|
156
|
+
const runnerUp = ranked[1];
|
|
157
|
+
const unambiguous = winner.rank > runnerUp.rank
|
|
158
|
+
|| winner.newestRegistration > runnerUp.newestRegistration;
|
|
159
|
+
if (unambiguous) {
|
|
160
|
+
selected.push(winner.candidate);
|
|
161
|
+
warnings.push(
|
|
162
|
+
`Using ${winner.candidate.projectDir} for development slug "${devSlug}"; ignored duplicate source(s): ${ranked.slice(1).map(({ candidate }) => candidate.projectDir).join(', ')}.`,
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
warnings.push(
|
|
167
|
+
`Skipped ambiguous development slug "${devSlug}" because multiple equally ranked sources are registered: ${ranked.map(({ candidate }) => candidate.projectDir).join(', ')}. Remove a root or set a unique devSlug.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return { selected, warnings };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function findSharedSourceBundleUrls(projectDirs, sessionsFilePath) {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const sourceSessions = readAppDevSessions(sessionsFilePath).sessions
|
|
176
|
+
.filter((session) => {
|
|
177
|
+
if (session.sourceHost !== true) return false;
|
|
178
|
+
const heartbeatAt = Date.parse(session.lastHeartbeatAt || '');
|
|
179
|
+
if (!Number.isFinite(heartbeatAt) || now - heartbeatAt > 45_000) return false;
|
|
180
|
+
if (!Number.isInteger(session.hostPid) || session.hostPid <= 0) return false;
|
|
181
|
+
try {
|
|
182
|
+
process.kill(session.hostPid, 0);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
const groups = new Map();
|
|
189
|
+
for (const session of sourceSessions) {
|
|
190
|
+
const key = `${session.hostPid || 0}:${session.sessionId}`;
|
|
191
|
+
groups.set(key, [...(groups.get(key) || []), session]);
|
|
192
|
+
}
|
|
193
|
+
for (const sessions of groups.values()) {
|
|
194
|
+
const byProject = new Map(sessions.map((session) => [session.projectDir, session]));
|
|
195
|
+
const canonicalProjects = sessions.find((session) => (
|
|
196
|
+
Array.isArray(session.canonicalProjects)
|
|
197
|
+
))?.canonicalProjects;
|
|
198
|
+
const discoveredProjects = sessions.find((session) => (
|
|
199
|
+
Array.isArray(session.discoveredProjects)
|
|
200
|
+
))?.discoveredProjects;
|
|
201
|
+
const discoveryMatches = discoveredProjects
|
|
202
|
+
&& JSON.stringify([...discoveredProjects].sort()) === JSON.stringify([...projectDirs].sort());
|
|
203
|
+
if (
|
|
204
|
+
discoveryMatches
|
|
205
|
+
&& canonicalProjects
|
|
206
|
+
&& canonicalProjects.every((projectDir) => byProject.has(projectDir))
|
|
207
|
+
) {
|
|
208
|
+
return new Map(canonicalProjects.map((projectDir) => [
|
|
209
|
+
projectDir,
|
|
210
|
+
byProject.get(projectDir).bundleBaseUrl,
|
|
211
|
+
]));
|
|
212
|
+
}
|
|
213
|
+
if (!projectDirs.every((projectDir) => byProject.has(projectDir))) continue;
|
|
214
|
+
return new Map(projectDirs.map((projectDir) => [
|
|
215
|
+
projectDir,
|
|
216
|
+
byProject.get(projectDir).bundleBaseUrl,
|
|
217
|
+
]));
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
69
221
|
const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
|
|
70
222
|
const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
|
|
71
223
|
const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
|
|
72
224
|
const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
|
|
73
225
|
const DUPLICATE_APP_TOOL = 'LOCAL_NOTIS_DUPLICATE_APP';
|
|
74
226
|
const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
|
|
227
|
+
export const APP_DEPLOY_TIMEOUT_MS = 600_000;
|
|
75
228
|
|
|
76
229
|
// ---------------------------------------------------------------------------
|
|
77
230
|
// Formatters
|
|
@@ -95,40 +248,12 @@ function scaffoldsTable(scaffolds) {
|
|
|
95
248
|
]);
|
|
96
249
|
}
|
|
97
250
|
|
|
98
|
-
function
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return [projectDir];
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const appsDir = join(projectDir, 'apps');
|
|
108
|
-
if (!existsSync(appsDir)) {
|
|
109
|
-
throw usageError(
|
|
110
|
-
`No notis.config.ts in ${projectDir}. Run from a Notis app project or a monorepo root with apps/<name>/notis.config.ts.`,
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const entries = readdirSync(appsDir, { withFileTypes: true });
|
|
115
|
-
const candidates = [];
|
|
116
|
-
for (const entry of entries) {
|
|
117
|
-
if (!entry.isDirectory()) continue;
|
|
118
|
-
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
119
|
-
const dir = join(appsDir, entry.name);
|
|
120
|
-
if (hasNotisConfig(dir)) {
|
|
121
|
-
candidates.push(dir);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (candidates.length === 0) {
|
|
126
|
-
throw usageError(
|
|
127
|
-
`Found apps/ in ${projectDir} but no apps/<name>/notis.config.ts inside it.`,
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return candidates;
|
|
251
|
+
function appDevRootsTable(roots) {
|
|
252
|
+
return formatTable(roots, [
|
|
253
|
+
{ label: 'Folder', value: (root) => root.path },
|
|
254
|
+
{ label: 'Source', value: (root) => root.implicit ? 'default' : 'registered' },
|
|
255
|
+
{ label: 'Registered', value: (root) => root.registeredAt || '' },
|
|
256
|
+
]);
|
|
132
257
|
}
|
|
133
258
|
|
|
134
259
|
function decodeJwtSub(jwt) {
|
|
@@ -143,42 +268,11 @@ function decodeJwtSub(jwt) {
|
|
|
143
268
|
}
|
|
144
269
|
}
|
|
145
270
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
bundleId = null,
|
|
152
|
-
scheme = 'notis',
|
|
153
|
-
} = {},
|
|
154
|
-
) {
|
|
155
|
-
let command;
|
|
156
|
-
let args;
|
|
157
|
-
if (platform === 'darwin') {
|
|
158
|
-
command = 'open';
|
|
159
|
-
args = bundleId && scheme === 'notis'
|
|
160
|
-
? ['-b', bundleId, url]
|
|
161
|
-
: appName && scheme === 'notis'
|
|
162
|
-
? ['-a', appName, url]
|
|
163
|
-
: [url];
|
|
164
|
-
} else if (platform === 'win32') {
|
|
165
|
-
command = 'cmd';
|
|
166
|
-
args = ['/c', 'start', '', url];
|
|
167
|
-
} else {
|
|
168
|
-
command = 'xdg-open';
|
|
169
|
-
args = [url];
|
|
170
|
-
}
|
|
171
|
-
return { command, args };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function openInBrowser(url, options = {}) {
|
|
175
|
-
const { command, args } = developmentDesktopOpenCommand(url, options);
|
|
176
|
-
try {
|
|
177
|
-
const child = spawn(command, args, { stdio: 'ignore' });
|
|
178
|
-
child.on('error', () => {});
|
|
179
|
-
} catch {
|
|
180
|
-
// Non-fatal. The URL is printed in the CLI output.
|
|
181
|
-
}
|
|
271
|
+
function linkedStateProfileKey(runtime) {
|
|
272
|
+
return appLinkedStateProfileKey({
|
|
273
|
+
apiBase: runtime?.apiBase,
|
|
274
|
+
userId: decodeJwtSub(runtime?.jwt),
|
|
275
|
+
});
|
|
182
276
|
}
|
|
183
277
|
|
|
184
278
|
function slugify(value) {
|
|
@@ -189,8 +283,8 @@ function slugify(value) {
|
|
|
189
283
|
.replace(/(^-|-$)+/g, '');
|
|
190
284
|
}
|
|
191
285
|
|
|
192
|
-
function buildDevInstallSlug(
|
|
193
|
-
const base = slugify(
|
|
286
|
+
function buildDevInstallSlug(appConfig) {
|
|
287
|
+
const base = slugify(appConfig?.devSlug || appConfig?.name);
|
|
194
288
|
if (!base) {
|
|
195
289
|
return '';
|
|
196
290
|
}
|
|
@@ -223,61 +317,6 @@ function pickDefaultRouteSlug(manifest) {
|
|
|
223
317
|
return firstWithSlug ? firstWithSlug.slug : null;
|
|
224
318
|
}
|
|
225
319
|
|
|
226
|
-
const DESKTOP_DEEP_LINK_SCHEME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
227
|
-
|
|
228
|
-
export function resolveDevelopmentDesktopScheme(env = process.env, worktreeRuntime = null) {
|
|
229
|
-
// Mirror the desktop app's own scheme resolution (electron main + forge config):
|
|
230
|
-
// local dev launches register `notis-dev`, while installed prod/beta builds claim
|
|
231
|
-
// `notis`. Hardcoding `notis` here is what made `apps dev` open the installed
|
|
232
|
-
// prod/beta app instead of the local dev app.
|
|
233
|
-
const scheme = (
|
|
234
|
-
worktreeRuntime?.desktop_deep_link_scheme
|
|
235
|
-
|| env.NOTIS_DESKTOP_DEEP_LINK_SCHEME
|
|
236
|
-
|| ''
|
|
237
|
-
).trim();
|
|
238
|
-
return DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export function resolveDevelopmentDesktopAppName(runtime = {}) {
|
|
242
|
-
const explicit = String(runtime.worktreeRuntime?.desktop_app_name || '').trim();
|
|
243
|
-
if (explicit) {
|
|
244
|
-
return explicit;
|
|
245
|
-
}
|
|
246
|
-
try {
|
|
247
|
-
return new URL(runtime.apiBase).hostname === 'api-beta.notis.ai'
|
|
248
|
-
? 'Notis Beta'
|
|
249
|
-
: 'Notis';
|
|
250
|
-
} catch {
|
|
251
|
-
return 'Notis';
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export function resolveDevelopmentDesktopBundleId(runtime = {}) {
|
|
256
|
-
return resolveDevelopmentDesktopAppName(runtime) === 'Notis Beta'
|
|
257
|
-
? 'ai.notis.desktop.beta'
|
|
258
|
-
: 'ai.notis.desktop';
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export function buildDevelopmentDesktopUrl(appHref = null, scheme = 'notis') {
|
|
262
|
-
const route = String(appHref || '/store').replace(/^\/+/, '');
|
|
263
|
-
const normalizedScheme = DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
|
|
264
|
-
return `${normalizedScheme}://${route || 'store'}`;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export function buildMountedDevelopmentDesktopUrl(appHref, scheme, sessionId) {
|
|
268
|
-
const url = new URL(buildDevelopmentDesktopUrl(appHref, scheme));
|
|
269
|
-
url.searchParams.set('notis_dev_session', sessionId);
|
|
270
|
-
return url.toString();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export function shouldOpenDevelopmentTab(options = {}) {
|
|
274
|
-
// Commander stores the negatable `--no-open` flag as `options.open === false`;
|
|
275
|
-
// it never sets `options.noOpen`. Reading the non-existent `noOpen` key meant
|
|
276
|
-
// `--no-open` was silently ignored and `apps dev` always ran `open notis://…`,
|
|
277
|
-
// which macOS routes to the installed prod/beta desktop app.
|
|
278
|
-
return options.open !== false;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
320
|
export function buildDevelopmentAppHref({
|
|
282
321
|
appSlug,
|
|
283
322
|
appId,
|
|
@@ -404,7 +443,17 @@ function harnessErrorMessage(error) {
|
|
|
404
443
|
return error.message || error.reason || error.type || JSON.stringify(error);
|
|
405
444
|
}
|
|
406
445
|
|
|
407
|
-
function
|
|
446
|
+
function runtimeCallLabel(call) {
|
|
447
|
+
if (call?.op === 'callTool') {
|
|
448
|
+
return call?.args?.name || 'callTool';
|
|
449
|
+
}
|
|
450
|
+
if (call?.op === 'request') {
|
|
451
|
+
return `request ${call?.args?.path || ''}`.trim();
|
|
452
|
+
}
|
|
453
|
+
return call?.op || 'runtime call';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
|
|
408
457
|
const assertions = [];
|
|
409
458
|
if (result.tool_error) {
|
|
410
459
|
assertions.push({
|
|
@@ -436,8 +485,9 @@ function assertHarnessResult(result, route, databaseSlugs) {
|
|
|
436
485
|
details: error,
|
|
437
486
|
});
|
|
438
487
|
}
|
|
488
|
+
const runtimeCalls = result.runtimeCalls || [];
|
|
439
489
|
const declaredDatabaseSet = new Set(databaseSlugs);
|
|
440
|
-
const databaseQueries =
|
|
490
|
+
const databaseQueries = runtimeCalls.filter(
|
|
441
491
|
(call) =>
|
|
442
492
|
call?.op === 'callTool'
|
|
443
493
|
&& localNotisToolSlug(call?.args?.name) === 'LOCAL_NOTIS_DATABASE_QUERY',
|
|
@@ -465,6 +515,36 @@ function assertHarnessResult(result, route, databaseSlugs) {
|
|
|
465
515
|
details: { databaseSlug: collectionDatabase },
|
|
466
516
|
});
|
|
467
517
|
}
|
|
518
|
+
if (mode === 'live') {
|
|
519
|
+
// In live mode an app that catches every failed call and renders its error
|
|
520
|
+
// state still mounts cleanly, so the render assertions above all pass. Only
|
|
521
|
+
// the recorded outcomes reveal that nothing real came back.
|
|
522
|
+
if (runtimeCalls.length > 0 && runtimeCalls.every((call) => call?.ok === false)) {
|
|
523
|
+
assertions.push({
|
|
524
|
+
ok: false,
|
|
525
|
+
code: 'all_runtime_calls_failed',
|
|
526
|
+
message: `Route "${route.slug}" rendered without data: all ${runtimeCalls.length} runtime call(s) failed. First error: ${runtimeCalls[0]?.error || 'unknown'}.`,
|
|
527
|
+
details: {
|
|
528
|
+
failed: runtimeCalls.map((call) => ({ call: runtimeCallLabel(call), error: call?.error || null })),
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
for (const databaseSlug of databaseSlugs) {
|
|
533
|
+
const queries = databaseQueries.filter(
|
|
534
|
+
(call) => call?.args?.arguments?.database_slug === databaseSlug,
|
|
535
|
+
);
|
|
536
|
+
// A call still in flight when the harness was read has ok === null; only
|
|
537
|
+
// an explicit failure with no successful sibling is a real problem.
|
|
538
|
+
if (queries.some((call) => call?.ok === false) && !queries.some((call) => call?.ok === true)) {
|
|
539
|
+
assertions.push({
|
|
540
|
+
ok: false,
|
|
541
|
+
code: 'failed_database_query',
|
|
542
|
+
message: `Route "${route.slug}" never got a successful "${databaseSlug}" query. Last error: ${queries[queries.length - 1]?.error || 'unknown'}.`,
|
|
543
|
+
details: { databaseSlug },
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
468
548
|
return assertions;
|
|
469
549
|
}
|
|
470
550
|
|
|
@@ -479,6 +559,9 @@ function renderVerifyReport({ summary, results, noBrowser }) {
|
|
|
479
559
|
lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
|
|
480
560
|
for (const assertion of result.assertions || []) {
|
|
481
561
|
lines.push(` - ${assertion.message}`);
|
|
562
|
+
for (const failure of assertion.details?.failed || []) {
|
|
563
|
+
lines.push(` ${failure.call}: ${failure.error || 'unknown error'}`);
|
|
564
|
+
}
|
|
482
565
|
}
|
|
483
566
|
}
|
|
484
567
|
if (noBrowser) {
|
|
@@ -514,9 +597,10 @@ function buildManifestForDev(appConfig) {
|
|
|
514
597
|
databases: appConfig.databases || [],
|
|
515
598
|
capabilities: normalizeAppCapabilities(appConfig.capabilities),
|
|
516
599
|
tools: appConfig.tools || [],
|
|
600
|
+
tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
|
|
517
601
|
skills: (appConfig.skills || []).map((skill) => ({
|
|
518
602
|
key: skill.key,
|
|
519
|
-
path:
|
|
603
|
+
path: normalizeAppSkillManifestPath(skill.path),
|
|
520
604
|
name: skill.name,
|
|
521
605
|
description: skill.description || null,
|
|
522
606
|
})),
|
|
@@ -524,8 +608,15 @@ function buildManifestForDev(appConfig) {
|
|
|
524
608
|
};
|
|
525
609
|
}
|
|
526
610
|
|
|
527
|
-
export function buildEnsureDevInstallArguments({
|
|
528
|
-
|
|
611
|
+
export function buildEnsureDevInstallArguments({
|
|
612
|
+
appConfig,
|
|
613
|
+
manifest,
|
|
614
|
+
linkedState,
|
|
615
|
+
skills,
|
|
616
|
+
useInstalledDatabases = false,
|
|
617
|
+
approvedCapabilities = null,
|
|
618
|
+
}) {
|
|
619
|
+
const devSlug = buildDevInstallSlug(appConfig);
|
|
529
620
|
const arguments_ = {
|
|
530
621
|
dev_slug: devSlug,
|
|
531
622
|
name: appConfig.name,
|
|
@@ -535,34 +626,117 @@ export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedStat
|
|
|
535
626
|
if (linkedState?.dev_app_id) {
|
|
536
627
|
arguments_.app_id = linkedState.dev_app_id;
|
|
537
628
|
}
|
|
629
|
+
if (useInstalledDatabases && linkedState?.app_id) {
|
|
630
|
+
arguments_.installed_app_id = linkedState.app_id;
|
|
631
|
+
}
|
|
632
|
+
// Sent on every ensure call, including as `false`, so a `--scratch` session
|
|
633
|
+
// puts the app on its own dev copies and the next plain session puts it back
|
|
634
|
+
// on the installed app's databases.
|
|
635
|
+
arguments_.use_installed_databases = Boolean(useInstalledDatabases);
|
|
636
|
+
if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
|
|
637
|
+
arguments_.approved_capabilities = approvedCapabilities;
|
|
638
|
+
}
|
|
538
639
|
return arguments_;
|
|
539
640
|
}
|
|
540
641
|
|
|
642
|
+
const CLOUD_SHELL_CONSENT_KEY = 'cloud_computer_shell_consent';
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Collect the developer's explicit approval for `cloudComputer: 'shell'`.
|
|
646
|
+
*
|
|
647
|
+
* The server never grants shell from authorship alone — holding an app's source
|
|
648
|
+
* (a scaffold, a pulled repo) is not consent to full-authority commands on the
|
|
649
|
+
* cloud computer — so `apps dev` asks once, records the answer in the linked
|
|
650
|
+
* state, and passes the grant with the ensure call. Runs before the parallel
|
|
651
|
+
* ensure fan-out so the prompt cannot interleave.
|
|
652
|
+
*/
|
|
653
|
+
export async function resolveCloudShellConsent({
|
|
654
|
+
appConfig,
|
|
655
|
+
projectDir,
|
|
656
|
+
grantCloudShell = false,
|
|
657
|
+
logger = console,
|
|
658
|
+
profileKey = null,
|
|
659
|
+
}) {
|
|
660
|
+
const capabilities = normalizeAppCapabilities(appConfig.capabilities);
|
|
661
|
+
if (capabilities.cloudComputer !== 'shell') {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
const approved = ['cloud_computer_read', 'cloud_computer_shell'];
|
|
665
|
+
const linkedState = readLinkedState(projectDir, profileKey) || {};
|
|
666
|
+
const recordDecision = (decision) => {
|
|
667
|
+
writeLinkedState(
|
|
668
|
+
projectDir,
|
|
669
|
+
{ ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision },
|
|
670
|
+
profileKey,
|
|
671
|
+
);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
if (grantCloudShell) {
|
|
675
|
+
recordDecision('granted');
|
|
676
|
+
return approved;
|
|
677
|
+
}
|
|
678
|
+
const recorded = linkedState[CLOUD_SHELL_CONSENT_KEY];
|
|
679
|
+
if (recorded === 'granted') {
|
|
680
|
+
return approved;
|
|
681
|
+
}
|
|
682
|
+
const declineHint =
|
|
683
|
+
`${appConfig.name}: cloud computer shell stays denied for this dev session. `
|
|
684
|
+
+ 'Re-run with --grant-cloud-shell to approve it.';
|
|
685
|
+
if (recorded === 'declined') {
|
|
686
|
+
logger.warn(declineHint);
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
690
|
+
logger.warn(declineHint);
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
const { createInterface } = await import('node:readline/promises');
|
|
694
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
695
|
+
try {
|
|
696
|
+
const answer = (await prompt.question(
|
|
697
|
+
`${appConfig.name} declares cloudComputer: 'shell' - its views will run commands and `
|
|
698
|
+
+ 'read or write files on your cloud computer with the same authority as your own '
|
|
699
|
+
+ 'agent. Allow for this dev app? [y/N] ',
|
|
700
|
+
)).trim().toLowerCase();
|
|
701
|
+
const granted = answer === 'y' || answer === 'yes';
|
|
702
|
+
recordDecision(granted ? 'granted' : 'declined');
|
|
703
|
+
if (!granted) {
|
|
704
|
+
logger.warn(declineHint);
|
|
705
|
+
}
|
|
706
|
+
return granted ? approved : null;
|
|
707
|
+
} finally {
|
|
708
|
+
prompt.close();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
541
712
|
export async function ensureDevInstall({
|
|
542
713
|
ctx,
|
|
543
714
|
appConfig,
|
|
544
715
|
projectDir,
|
|
545
716
|
idempotencyKey,
|
|
717
|
+
useInstalledDatabases = false,
|
|
718
|
+
approvedCapabilities = null,
|
|
546
719
|
runTool = runToolCommand,
|
|
547
720
|
}) {
|
|
548
721
|
if (!appConfig.name) {
|
|
549
722
|
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
550
723
|
}
|
|
551
|
-
const devSlug = buildDevInstallSlug(appConfig
|
|
724
|
+
const devSlug = buildDevInstallSlug(appConfig);
|
|
552
725
|
if (!devSlug) {
|
|
553
|
-
throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
|
|
726
|
+
throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
|
|
554
727
|
}
|
|
555
728
|
|
|
556
729
|
const manifest = buildManifestForDev(appConfig);
|
|
557
730
|
const skills = resolveConfiguredAppSkills(appConfig, projectDir);
|
|
558
|
-
|
|
731
|
+
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
732
|
+
let linkedState = readLinkedState(projectDir, profileKey);
|
|
559
733
|
let linkedApp = null;
|
|
560
734
|
if (linkedState?.dev_app_id) {
|
|
561
735
|
const devApp = await getAccessibleApp(ctx.runtime, linkedState.dev_app_id, runTool);
|
|
562
736
|
if (!devApp || devApp.manifest?.is_dev !== true) {
|
|
563
737
|
const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
|
|
564
738
|
linkedState = rest;
|
|
565
|
-
writeLinkedState(projectDir, linkedState);
|
|
739
|
+
writeLinkedState(projectDir, linkedState, profileKey);
|
|
566
740
|
}
|
|
567
741
|
}
|
|
568
742
|
if (linkedState?.app_id) {
|
|
@@ -577,18 +751,98 @@ export async function ensureDevInstall({
|
|
|
577
751
|
dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
|
|
578
752
|
} : {}),
|
|
579
753
|
};
|
|
580
|
-
writeLinkedState(projectDir, linkedState);
|
|
754
|
+
writeLinkedState(projectDir, linkedState, profileKey);
|
|
581
755
|
linkedApp = null;
|
|
582
756
|
}
|
|
583
757
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
758
|
+
// A checkout can be shared by Beta and a source-workspace desktop. Its
|
|
759
|
+
// installed app link is valid for the Beta account but intentionally
|
|
760
|
+
// inaccessible to the worktree's test user. Preserve that durable link for
|
|
761
|
+
// Beta, while omitting it from this run so the local dev app uses its own
|
|
762
|
+
// resources instead of sending an unauthorized installed_app_id to ensure.
|
|
763
|
+
if (!linkedState?.app_id) {
|
|
764
|
+
const installedResolution = await findInstalledLinkCandidatesForLegacyDevState(
|
|
765
|
+
ctx.runtime,
|
|
766
|
+
devSlug,
|
|
767
|
+
linkedState?.dev_app_id,
|
|
768
|
+
projectDir,
|
|
769
|
+
profileKey,
|
|
770
|
+
runTool,
|
|
771
|
+
);
|
|
772
|
+
const installedCandidates = installedResolution.candidates;
|
|
773
|
+
if (installedCandidates.length > 1) {
|
|
774
|
+
if (installedResolution.source === 'persisted-project-link') {
|
|
775
|
+
throw usageError(
|
|
776
|
+
`Multiple accessible installed apps are persisted for ${projectDir}. `
|
|
777
|
+
+ `Run \`notis apps link <app-id> ${projectDir}\` to choose one for this environment.`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
throw usageError(
|
|
781
|
+
`Multiple accessible installed apps use the exact slug "${devSlug.slice(0, -4)}". `
|
|
782
|
+
+ `Run \`notis apps link <app-id> ${projectDir}\` to choose one.`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
if (installedCandidates.length === 1) {
|
|
786
|
+
const now = new Date().toISOString();
|
|
787
|
+
linkedApp = installedCandidates[0];
|
|
788
|
+
linkedState = {
|
|
789
|
+
...(linkedState || {}),
|
|
790
|
+
app_id: linkedApp.app_id || linkedApp.id,
|
|
791
|
+
linked_at: now,
|
|
792
|
+
auto_linked_at: now,
|
|
793
|
+
};
|
|
794
|
+
writeLinkedState(projectDir, linkedState, profileKey);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const runtimeLinkedState = linkedState?.app_id && !linkedApp
|
|
798
|
+
? Object.fromEntries(
|
|
799
|
+
Object.entries(linkedState).filter(([key]) => ![
|
|
800
|
+
'app_id', 'linked_at', 'deployed_at', 'version',
|
|
801
|
+
].includes(key)),
|
|
802
|
+
)
|
|
803
|
+
: linkedState;
|
|
804
|
+
const ensureArguments = buildEnsureDevInstallArguments({
|
|
805
|
+
appConfig,
|
|
806
|
+
manifest,
|
|
807
|
+
linkedState: runtimeLinkedState,
|
|
808
|
+
skills,
|
|
809
|
+
useInstalledDatabases,
|
|
810
|
+
approvedCapabilities,
|
|
591
811
|
});
|
|
812
|
+
let ensureResult;
|
|
813
|
+
try {
|
|
814
|
+
ensureResult = await runTool({
|
|
815
|
+
runtime: ctx.runtime,
|
|
816
|
+
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
817
|
+
arguments_: ensureArguments,
|
|
818
|
+
mutating: true,
|
|
819
|
+
idempotencyKey,
|
|
820
|
+
});
|
|
821
|
+
} catch (error) {
|
|
822
|
+
const backendCode = error?.details?.error?.code;
|
|
823
|
+
if (linkedState?.dev_app_id && backendCode === 'dev_app_slug_conflict') {
|
|
824
|
+
// The authenticated environment can outlive an older local API profile.
|
|
825
|
+
// If that profile remembered a different dev row while this user already
|
|
826
|
+
// owns the exact canonical dev slug, the backend refuses a duplicate.
|
|
827
|
+
// Retry without the stale row id so the backend deterministically adopts
|
|
828
|
+
// the unique slug owner; preserve the installed target and every grant.
|
|
829
|
+
const staleDevAppId = linkedState.dev_app_id;
|
|
830
|
+
const { dev_app_id: _devAppId, dev_linked_at: _devLinkedAt, ...rest } = linkedState;
|
|
831
|
+
linkedState = rest;
|
|
832
|
+
writeLinkedState(projectDir, linkedState, profileKey);
|
|
833
|
+
const retryArguments = { ...ensureArguments };
|
|
834
|
+
delete retryArguments.app_id;
|
|
835
|
+
ensureResult = await runTool({
|
|
836
|
+
runtime: ctx.runtime,
|
|
837
|
+
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
838
|
+
arguments_: retryArguments,
|
|
839
|
+
mutating: true,
|
|
840
|
+
idempotencyKey: `${idempotencyKey}:adopt-slug-owner:${staleDevAppId}`,
|
|
841
|
+
});
|
|
842
|
+
} else {
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
592
846
|
const appId = ensureResult.payload.app_id;
|
|
593
847
|
if (!appId) {
|
|
594
848
|
throw usageError('Dev installation tool did not return an app_id.');
|
|
@@ -599,7 +853,7 @@ export async function ensureDevInstall({
|
|
|
599
853
|
...(linkedState || {}),
|
|
600
854
|
dev_app_id: appId,
|
|
601
855
|
dev_linked_at: linkedState?.dev_linked_at || now,
|
|
602
|
-
});
|
|
856
|
+
}, profileKey);
|
|
603
857
|
}
|
|
604
858
|
return {
|
|
605
859
|
slug: ensureResult.payload.slug || devSlug,
|
|
@@ -609,10 +863,11 @@ export async function ensureDevInstall({
|
|
|
609
863
|
projectDir,
|
|
610
864
|
manifest,
|
|
611
865
|
created: ensureResult.payload.created || false,
|
|
612
|
-
linkedAppId:
|
|
613
|
-
targetAppId:
|
|
866
|
+
linkedAppId: runtimeLinkedState?.app_id || null,
|
|
867
|
+
targetAppId: runtimeLinkedState?.app_id || null,
|
|
614
868
|
targetAppSlug: linkedApp?.slug || null,
|
|
615
869
|
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
870
|
+
liveData: ensureResult.payload.live_data || null,
|
|
616
871
|
};
|
|
617
872
|
}
|
|
618
873
|
|
|
@@ -631,6 +886,12 @@ function databaseMaterializationWarnings(apps) {
|
|
|
631
886
|
return warnings;
|
|
632
887
|
}
|
|
633
888
|
|
|
889
|
+
function liveDataWarnings(apps) {
|
|
890
|
+
return apps
|
|
891
|
+
.filter((app) => app.liveData?.warning)
|
|
892
|
+
.map((app) => `${app.name}: ${app.liveData.warning}`);
|
|
893
|
+
}
|
|
894
|
+
|
|
634
895
|
async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
635
896
|
const result = await runTool({
|
|
636
897
|
runtime,
|
|
@@ -654,6 +915,63 @@ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
|
654
915
|
throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
|
|
655
916
|
}
|
|
656
917
|
|
|
918
|
+
async function findInstalledLinkCandidatesForLegacyDevState(
|
|
919
|
+
runtime,
|
|
920
|
+
devSlug,
|
|
921
|
+
devAppId,
|
|
922
|
+
projectDir,
|
|
923
|
+
profileKey,
|
|
924
|
+
runTool = runToolCommand,
|
|
925
|
+
) {
|
|
926
|
+
const installedSlug = devSlug.endsWith('-dev') ? devSlug.slice(0, -4) : '';
|
|
927
|
+
if (!installedSlug) return { candidates: [], source: null };
|
|
928
|
+
|
|
929
|
+
const result = await runTool({
|
|
930
|
+
runtime,
|
|
931
|
+
toolName: LIST_APPS_TOOL,
|
|
932
|
+
});
|
|
933
|
+
const accessibleInstalledApps = (result.payload?.apps || []).filter((app) => (
|
|
934
|
+
(app?.app_id || app?.id)
|
|
935
|
+
&& (app.app_id || app.id) !== devAppId
|
|
936
|
+
&& app?.manifest?.is_dev !== true
|
|
937
|
+
));
|
|
938
|
+
|
|
939
|
+
// A project-local app id is stronger evidence than a slug. Older CLI runs
|
|
940
|
+
// could persist that explicit link under another API/user profile before
|
|
941
|
+
// the machine-local discovery host mounted the same source here. Migrate it
|
|
942
|
+
// only when exactly one such id is accessible in the current environment;
|
|
943
|
+
// multiple accessible ids remain ambiguous and fail closed below.
|
|
944
|
+
const rawState = readLinkedState(projectDir) || {};
|
|
945
|
+
const persistedAppIds = new Set();
|
|
946
|
+
if (rawState.app_id) persistedAppIds.add(rawState.app_id);
|
|
947
|
+
const profiles = rawState.profiles && typeof rawState.profiles === 'object'
|
|
948
|
+
? rawState.profiles
|
|
949
|
+
: {};
|
|
950
|
+
for (const [storedProfileKey, state] of Object.entries(profiles)) {
|
|
951
|
+
if (storedProfileKey === profileKey || !state || typeof state !== 'object') continue;
|
|
952
|
+
if (state.app_id) persistedAppIds.add(state.app_id);
|
|
953
|
+
}
|
|
954
|
+
const persistedCandidates = accessibleInstalledApps.filter((app) => (
|
|
955
|
+
persistedAppIds.has(app.app_id || app.id)
|
|
956
|
+
));
|
|
957
|
+
if (persistedCandidates.length) {
|
|
958
|
+
return { candidates: persistedCandidates, source: 'persisted-project-link' };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// The database row slug can change when a Store app is installed or an old
|
|
962
|
+
// development app is promoted. The shipped source manifest keeps the
|
|
963
|
+
// canonical app slug, so accept either exact field. Display names are never
|
|
964
|
+
// considered.
|
|
965
|
+
const exactSlugCandidates = accessibleInstalledApps.filter((app) => {
|
|
966
|
+
const canonicalSlugs = new Set([
|
|
967
|
+
app?.slug,
|
|
968
|
+
app?.manifest?.app?.slug,
|
|
969
|
+
].map(slugify).filter(Boolean));
|
|
970
|
+
return canonicalSlugs.has(installedSlug);
|
|
971
|
+
});
|
|
972
|
+
return { candidates: exactSlugCandidates, source: 'exact-slug' };
|
|
973
|
+
}
|
|
974
|
+
|
|
657
975
|
export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
|
|
658
976
|
const app = await getAccessibleApp(runtime, appId, runTool);
|
|
659
977
|
if (!app) {
|
|
@@ -662,6 +980,14 @@ export async function assertDirectDeployAccess(runtime, appId, runTool = runTool
|
|
|
662
980
|
if (app.apps_access?.has_access !== true) {
|
|
663
981
|
throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
|
|
664
982
|
}
|
|
983
|
+
if (app.can_edit !== true) {
|
|
984
|
+
throw usageError(`Direct deploy requires edit access to app ${appId}.`);
|
|
985
|
+
}
|
|
986
|
+
if (app.manifest?.is_dev === true) {
|
|
987
|
+
throw usageError(
|
|
988
|
+
'A development app cannot be deployed directly. Retry without --direct so first deploy can promote it safely.',
|
|
989
|
+
);
|
|
990
|
+
}
|
|
665
991
|
return app;
|
|
666
992
|
}
|
|
667
993
|
|
|
@@ -698,10 +1024,12 @@ async function appsListHandler(ctx) {
|
|
|
698
1024
|
}
|
|
699
1025
|
|
|
700
1026
|
async function appsInitHandler(ctx) {
|
|
701
|
-
const projectDir =
|
|
1027
|
+
const projectDir = ctx.args.dir
|
|
1028
|
+
? resolveProjectDir(ctx.args.dir)
|
|
1029
|
+
: defaultAppProjectDir(slugify(ctx.args.name));
|
|
702
1030
|
const fromSlug = ctx.options.from || null;
|
|
703
1031
|
|
|
704
|
-
scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
|
|
1032
|
+
await scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
|
|
705
1033
|
|
|
706
1034
|
return ctx.output.emitSuccess({
|
|
707
1035
|
command: ctx.spec.command_path.join(' '),
|
|
@@ -717,14 +1045,20 @@ async function appsInitHandler(ctx) {
|
|
|
717
1045
|
}
|
|
718
1046
|
|
|
719
1047
|
async function appsScaffoldsListHandler(ctx) {
|
|
720
|
-
const
|
|
1048
|
+
const searchTerm = ctx.options.search || null;
|
|
1049
|
+
const catalog = await loadScaffoldCatalog();
|
|
1050
|
+
const scaffolds = filterScaffoldCatalog(catalog, searchTerm);
|
|
1051
|
+
const registry = scaffoldRegistryLabel();
|
|
1052
|
+
const emptyMessage = searchTerm
|
|
1053
|
+
? `No published scaffolds match "${searchTerm}" (${catalog.length} available; run without --search to see all).`
|
|
1054
|
+
: `No published scaffolds found in ${registry}.`;
|
|
721
1055
|
return ctx.output.emitSuccess({
|
|
722
1056
|
command: ctx.spec.command_path.join(' '),
|
|
723
|
-
data: { scaffolds },
|
|
1057
|
+
data: { scaffolds, registry, search: searchTerm },
|
|
724
1058
|
humanSummary: scaffolds.length
|
|
725
|
-
? `Found ${scaffolds.length}
|
|
726
|
-
:
|
|
727
|
-
renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) :
|
|
1059
|
+
? `Found ${scaffolds.length} published scaffolds in ${registry}`
|
|
1060
|
+
: emptyMessage,
|
|
1061
|
+
renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : emptyMessage),
|
|
728
1062
|
});
|
|
729
1063
|
}
|
|
730
1064
|
|
|
@@ -754,7 +1088,7 @@ async function appsCreateHandler(ctx) {
|
|
|
754
1088
|
writeLinkedState(projectDir, {
|
|
755
1089
|
app_id: app.id,
|
|
756
1090
|
linked_at: new Date().toISOString(),
|
|
757
|
-
});
|
|
1091
|
+
}, linkedStateProfileKey(ctx.runtime));
|
|
758
1092
|
}
|
|
759
1093
|
|
|
760
1094
|
return ctx.output.emitSuccess({
|
|
@@ -777,7 +1111,94 @@ async function appsCreateHandler(ctx) {
|
|
|
777
1111
|
|
|
778
1112
|
async function appsDevHandler(ctx) {
|
|
779
1113
|
const rootDir = resolveProjectDir(ctx.args.dir || '.');
|
|
780
|
-
const
|
|
1114
|
+
const skipRootRegistration = process.env.NOTIS_APPS_DEV_ALL_REGISTERED_ROOTS === '1';
|
|
1115
|
+
let appDirs = discoverAppDevLaunchProjects(rootDir, { skipRootRegistration });
|
|
1116
|
+
const discoveredAppDirs = [...appDirs];
|
|
1117
|
+
const sessionsFilePath = getAppDevSessionsFile();
|
|
1118
|
+
let sharedBundleBaseUrls = null;
|
|
1119
|
+
const sharedBundlesRaw = process.env.NOTIS_APPS_DEV_SHARED_BUNDLE_URLS;
|
|
1120
|
+
if (sharedBundlesRaw) {
|
|
1121
|
+
let parsed;
|
|
1122
|
+
try {
|
|
1123
|
+
parsed = JSON.parse(sharedBundlesRaw);
|
|
1124
|
+
} catch {
|
|
1125
|
+
throw usageError('The shared app development bundle map is invalid.');
|
|
1126
|
+
}
|
|
1127
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1128
|
+
throw usageError('The shared app development bundle map must be an object.');
|
|
1129
|
+
}
|
|
1130
|
+
const sharedProjectDirs = Object.keys(parsed);
|
|
1131
|
+
const discoveredProjects = new Set(appDirs);
|
|
1132
|
+
for (const projectDir of sharedProjectDirs) {
|
|
1133
|
+
if (!discoveredProjects.has(projectDir)) {
|
|
1134
|
+
throw usageError(`The shared bundle map references an unregistered app: ${projectDir}.`);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
appDirs = sharedProjectDirs;
|
|
1138
|
+
sharedBundleBaseUrls = new Map();
|
|
1139
|
+
for (const projectDir of appDirs) {
|
|
1140
|
+
const value = parsed[projectDir];
|
|
1141
|
+
let url;
|
|
1142
|
+
try {
|
|
1143
|
+
url = new URL(String(value || ''));
|
|
1144
|
+
} catch {
|
|
1145
|
+
throw usageError(`The shared bundle URL is missing or invalid for ${projectDir}.`);
|
|
1146
|
+
}
|
|
1147
|
+
if (
|
|
1148
|
+
url.protocol !== 'http:'
|
|
1149
|
+
|| !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
|
|
1150
|
+
) {
|
|
1151
|
+
throw usageError(`The shared bundle URL must use loopback HTTP for ${projectDir}.`);
|
|
1152
|
+
}
|
|
1153
|
+
sharedBundleBaseUrls.set(projectDir, url.toString().replace(/\/$/, ''));
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (appDirs.length === 0) {
|
|
1158
|
+
return ctx.output.emitSuccess({
|
|
1159
|
+
command: ctx.spec.command_path.join(' '),
|
|
1160
|
+
data: {
|
|
1161
|
+
registered_root: skipRootRegistration ? null : rootDir,
|
|
1162
|
+
apps: [],
|
|
1163
|
+
watching_on_desktop_launch: true,
|
|
1164
|
+
},
|
|
1165
|
+
humanSummary: [
|
|
1166
|
+
skipRootRegistration
|
|
1167
|
+
? 'No registered Notis apps are present yet.'
|
|
1168
|
+
: `Registered app development root: ${rootDir}`,
|
|
1169
|
+
'No Notis apps are present yet. A running Notis Desktop will discover and mount apps created here automatically.',
|
|
1170
|
+
].join('\n'),
|
|
1171
|
+
hints: [
|
|
1172
|
+
{ command: 'notis apps roots list', reason: 'Show every persistent development root' },
|
|
1173
|
+
],
|
|
1174
|
+
meta: { mutating: true },
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (!sharedBundleBaseUrls) {
|
|
1179
|
+
sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
|
|
1180
|
+
}
|
|
1181
|
+
let sourceHostLock = null;
|
|
1182
|
+
if (
|
|
1183
|
+
!sharedBundleBaseUrls
|
|
1184
|
+
&& process.env.NOTIS_APPS_DEV_HOST_LOCK_HELD !== '1'
|
|
1185
|
+
) {
|
|
1186
|
+
sourceHostLock = tryAcquireAppDevHostLock({
|
|
1187
|
+
identity: '__mac_user__',
|
|
1188
|
+
apiBase: 'loopback-source',
|
|
1189
|
+
projectDir: SHARED_APP_DEV_HOST_KEY,
|
|
1190
|
+
});
|
|
1191
|
+
if (!sourceHostLock) {
|
|
1192
|
+
const startedAt = Date.now();
|
|
1193
|
+
while (!sharedBundleBaseUrls && Date.now() - startedAt < 45_000) {
|
|
1194
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
|
|
1195
|
+
sharedBundleBaseUrls = findSharedSourceBundleUrls(appDirs, sessionsFilePath);
|
|
1196
|
+
}
|
|
1197
|
+
if (!sharedBundleBaseUrls) {
|
|
1198
|
+
throw usageError('The shared app development host is busy but did not become ready. Retry after checking ~/.notis/app-dev-host.log.');
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
781
1202
|
|
|
782
1203
|
for (const projectDir of appDirs) {
|
|
783
1204
|
const problems = detectProjectProblems(projectDir);
|
|
@@ -786,9 +1207,19 @@ async function appsDevHandler(ctx) {
|
|
|
786
1207
|
}
|
|
787
1208
|
}
|
|
788
1209
|
|
|
789
|
-
|
|
790
|
-
if (
|
|
791
|
-
|
|
1210
|
+
let port = null;
|
|
1211
|
+
if (sharedBundleBaseUrls) {
|
|
1212
|
+
// The source host owns the only loopback listener and build watchers.
|
|
1213
|
+
} else if (ctx.options.port) {
|
|
1214
|
+
port = Number.parseInt(ctx.options.port, 10);
|
|
1215
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1216
|
+
throw usageError('Port must be between 1 and 65535.');
|
|
1217
|
+
}
|
|
1218
|
+
} else {
|
|
1219
|
+
// Concurrent sessions must not fight over the default port: the desktop
|
|
1220
|
+
// auto-starts every known project at launch, so only the first one can
|
|
1221
|
+
// have 5173 and the rest fall back to an ephemeral port.
|
|
1222
|
+
port = await getAvailablePortPreferring(DEFAULT_DEV_PORT);
|
|
792
1223
|
}
|
|
793
1224
|
|
|
794
1225
|
const mode = getCliMode();
|
|
@@ -797,16 +1228,39 @@ async function appsDevHandler(ctx) {
|
|
|
797
1228
|
throw usageError('Could not determine the current user from the CLI credential. Run notis login and retry.');
|
|
798
1229
|
}
|
|
799
1230
|
const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
|
|
800
|
-
const sessionsFilePath = getAppDevSessionsFile(
|
|
801
|
-
ctx.runtime.worktreeRuntime?.app_dev_sessions_file,
|
|
802
|
-
);
|
|
803
|
-
const desktopScheme = resolveDevelopmentDesktopScheme(
|
|
804
|
-
process.env,
|
|
805
|
-
ctx.runtime.worktreeRuntime,
|
|
806
|
-
);
|
|
807
|
-
const desktopAppName = resolveDevelopmentDesktopAppName(ctx.runtime);
|
|
808
|
-
const desktopBundleId = resolveDevelopmentDesktopBundleId(ctx.runtime);
|
|
809
1231
|
const sessionId = randomUUID();
|
|
1232
|
+
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
1233
|
+
const consumerMode = process.env.NOTIS_APPS_DEV_CONSUMER_MODE;
|
|
1234
|
+
const manualConsumerInstanceId = sharedBundleBaseUrls && !consumerMode
|
|
1235
|
+
? `cli.${process.pid}.${sessionId}`
|
|
1236
|
+
: null;
|
|
1237
|
+
let manualConsumerTimer = null;
|
|
1238
|
+
if (manualConsumerInstanceId) {
|
|
1239
|
+
const heartbeatManualConsumer = () => heartbeatAppDevConsumer({
|
|
1240
|
+
instanceId: manualConsumerInstanceId,
|
|
1241
|
+
userId: identity,
|
|
1242
|
+
apiBase,
|
|
1243
|
+
pid: process.pid,
|
|
1244
|
+
});
|
|
1245
|
+
heartbeatManualConsumer();
|
|
1246
|
+
manualConsumerTimer = setInterval(() => {
|
|
1247
|
+
try {
|
|
1248
|
+
heartbeatManualConsumer();
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1251
|
+
process.stderr.write(`[notis apps dev] consumer heartbeat failed: ${message}\n`);
|
|
1252
|
+
}
|
|
1253
|
+
}, DEV_CONSUMER_HEARTBEAT_INTERVAL_MS);
|
|
1254
|
+
}
|
|
1255
|
+
// One app, one dataset: a dev session runs the app from local source against
|
|
1256
|
+
// the same databases the installed app uses, the way `npm run dev` serves the
|
|
1257
|
+
// same database as the deployed site. `--scratch` is the marked case; the
|
|
1258
|
+
// old `--live-data` flag now names the default and is kept as a no-op so
|
|
1259
|
+
// muscle memory and scripts keep working.
|
|
1260
|
+
if (ctx.options?.scratch && ctx.options?.liveData) {
|
|
1261
|
+
throw usageError('--scratch and --live-data contradict each other; pass at most one.');
|
|
1262
|
+
}
|
|
1263
|
+
const useInstalledDatabases = !ctx.options?.scratch;
|
|
810
1264
|
|
|
811
1265
|
const candidates = [];
|
|
812
1266
|
for (const projectDir of appDirs) {
|
|
@@ -814,111 +1268,58 @@ async function appsDevHandler(ctx) {
|
|
|
814
1268
|
if (!appConfig.name) {
|
|
815
1269
|
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
816
1270
|
}
|
|
817
|
-
const devSlug = buildDevInstallSlug(appConfig
|
|
1271
|
+
const devSlug = buildDevInstallSlug(appConfig);
|
|
818
1272
|
if (!devSlug) {
|
|
819
|
-
throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
|
|
1273
|
+
throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
|
|
820
1274
|
}
|
|
821
1275
|
candidates.push({ appConfig, devSlug, projectDir });
|
|
822
1276
|
}
|
|
823
1277
|
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1278
|
+
const canonicalSelection = selectCanonicalDevApps(candidates, readAppDevRoots());
|
|
1279
|
+
const canonicalCandidates = [];
|
|
1280
|
+
for (const { appConfig, devSlug, projectDir } of canonicalSelection.selected) {
|
|
1281
|
+
// Sequential on purpose: the shell-consent prompt must never interleave
|
|
1282
|
+
// with another app's, and the ensure fan-out below runs in parallel.
|
|
1283
|
+
const approvedCapabilities = await resolveCloudShellConsent({
|
|
1284
|
+
appConfig,
|
|
1285
|
+
projectDir,
|
|
1286
|
+
grantCloudShell: Boolean(ctx.options?.grantCloudShell),
|
|
1287
|
+
profileKey,
|
|
1288
|
+
});
|
|
1289
|
+
canonicalCandidates.push({
|
|
1290
|
+
appConfig,
|
|
1291
|
+
devSlug,
|
|
1292
|
+
projectDir,
|
|
1293
|
+
approvedCapabilities,
|
|
1294
|
+
mountNonce: randomUUID(),
|
|
1295
|
+
});
|
|
832
1296
|
}
|
|
833
1297
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
});
|
|
845
|
-
} finally {
|
|
846
|
-
logAppsTiming('ensure-dev-install', {
|
|
847
|
-
slug: devSlug,
|
|
848
|
-
ms: timingMs(appStartedAt).toFixed(1),
|
|
849
|
-
});
|
|
1298
|
+
// Start every build watcher before the backend registrations finish. A
|
|
1299
|
+
// machine with several apps must not show an empty sidebar for minutes while
|
|
1300
|
+
// unrelated registrations run sequentially. Each successful registration is
|
|
1301
|
+
// published below as soon as its own last-known bundle is ready.
|
|
1302
|
+
const devServer = sharedBundleBaseUrls
|
|
1303
|
+
? {
|
|
1304
|
+
close: async () => {},
|
|
1305
|
+
isBundleReady: () => true,
|
|
1306
|
+
updateApp: () => {},
|
|
1307
|
+
waitForBundle: async () => {},
|
|
850
1308
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
appSlug: app.slug,
|
|
866
|
-
appId: app.appId,
|
|
867
|
-
devSlug: app.devSlug,
|
|
868
|
-
targetAppId: app.targetAppId,
|
|
869
|
-
targetAppSlug: app.targetAppSlug,
|
|
870
|
-
manifest: app.manifest,
|
|
871
|
-
}),
|
|
872
|
-
};
|
|
873
|
-
});
|
|
874
|
-
const developmentTabUrl = buildDevelopmentDesktopUrl(
|
|
875
|
-
apps[0]?.appHref,
|
|
876
|
-
desktopScheme,
|
|
877
|
-
);
|
|
878
|
-
const desktopWakeUrl = buildDevelopmentDesktopUrl('/manage', desktopScheme);
|
|
879
|
-
const mountedDevelopmentTabUrl = buildMountedDevelopmentDesktopUrl(
|
|
880
|
-
apps[0]?.appHref,
|
|
881
|
-
desktopScheme,
|
|
882
|
-
sessionId,
|
|
883
|
-
);
|
|
884
|
-
const warnings = databaseMaterializationWarnings(apps);
|
|
885
|
-
|
|
886
|
-
const devServer = await startAppDevServer({
|
|
887
|
-
apps: apps.map((app) => ({
|
|
888
|
-
slug: app.devSlug,
|
|
889
|
-
projectDir: app.projectDir,
|
|
890
|
-
appId: app.appId,
|
|
891
|
-
targetAppId: app.targetAppId || null,
|
|
892
|
-
userId: identity,
|
|
893
|
-
})),
|
|
894
|
-
port,
|
|
895
|
-
sessionsFilePath,
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
try {
|
|
899
|
-
const now = new Date().toISOString();
|
|
900
|
-
upsertAppDevSessions(apps.map((app) => ({
|
|
901
|
-
sessionId,
|
|
902
|
-
userId: identity,
|
|
903
|
-
apiBase,
|
|
904
|
-
appId: app.appId,
|
|
905
|
-
targetAppId: app.targetAppId || undefined,
|
|
906
|
-
mountNonce: app.mountNonce,
|
|
907
|
-
devSlug: app.devSlug,
|
|
908
|
-
bundleBaseUrl: app.bundleBaseUrl,
|
|
909
|
-
projectDir: app.projectDir,
|
|
910
|
-
startedAt: now,
|
|
911
|
-
lastHeartbeatAt: now,
|
|
912
|
-
desktopAppName,
|
|
913
|
-
})), sessionsFilePath);
|
|
914
|
-
} catch (error) {
|
|
915
|
-
try {
|
|
916
|
-
await devServer.close();
|
|
917
|
-
} catch {
|
|
918
|
-
// ignore cleanup failure on registry errors
|
|
919
|
-
}
|
|
920
|
-
throw error;
|
|
921
|
-
}
|
|
1309
|
+
: await startAppDevServer({
|
|
1310
|
+
apps: canonicalCandidates.map((app) => ({
|
|
1311
|
+
slug: app.devSlug,
|
|
1312
|
+
projectDir: app.projectDir,
|
|
1313
|
+
appId: null,
|
|
1314
|
+
targetAppId: null,
|
|
1315
|
+
userId: identity,
|
|
1316
|
+
profileKey,
|
|
1317
|
+
sessionId,
|
|
1318
|
+
mountNonce: app.mountNonce,
|
|
1319
|
+
})),
|
|
1320
|
+
port,
|
|
1321
|
+
sessionsFilePath,
|
|
1322
|
+
});
|
|
922
1323
|
|
|
923
1324
|
let heartbeatTimer = setInterval(() => {
|
|
924
1325
|
try {
|
|
@@ -929,23 +1330,144 @@ async function appsDevHandler(ctx) {
|
|
|
929
1330
|
}
|
|
930
1331
|
}, DEV_HEARTBEAT_INTERVAL_MS);
|
|
931
1332
|
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1333
|
+
const registrationStartedAt = process.hrtime.bigint();
|
|
1334
|
+
const apps = [];
|
|
1335
|
+
const registrationWarnings = [];
|
|
1336
|
+
let firstRegistrationError = null;
|
|
1337
|
+
// The local development backend is deliberately a single worker. Register
|
|
1338
|
+
// apps sequentially so a large root cannot queue many long mutations behind
|
|
1339
|
+
// one another. Unlike the old all-or-nothing startup, publish each healthy
|
|
1340
|
+
// app immediately while later registrations continue in the background.
|
|
1341
|
+
for (const {
|
|
1342
|
+
appConfig,
|
|
1343
|
+
devSlug,
|
|
1344
|
+
projectDir,
|
|
1345
|
+
approvedCapabilities,
|
|
1346
|
+
mountNonce,
|
|
1347
|
+
} of canonicalCandidates) {
|
|
1348
|
+
const appStartedAt = process.hrtime.bigint();
|
|
1349
|
+
try {
|
|
1350
|
+
const registeredApp = await ensureDevInstall({
|
|
1351
|
+
ctx,
|
|
1352
|
+
appConfig,
|
|
1353
|
+
projectDir,
|
|
1354
|
+
idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
|
|
1355
|
+
useInstalledDatabases,
|
|
1356
|
+
approvedCapabilities,
|
|
1357
|
+
});
|
|
1358
|
+
const bundleBaseUrl = sharedBundleBaseUrls?.get(registeredApp.projectDir)
|
|
1359
|
+
|| `http://127.0.0.1:${port}/a/${registeredApp.devSlug}`;
|
|
1360
|
+
const app = {
|
|
1361
|
+
...registeredApp,
|
|
1362
|
+
bundleBaseUrl,
|
|
1363
|
+
mountNonce,
|
|
1364
|
+
appHref: buildDevelopmentAppHref({
|
|
1365
|
+
appSlug: registeredApp.slug,
|
|
1366
|
+
appId: registeredApp.appId,
|
|
1367
|
+
devSlug: registeredApp.devSlug,
|
|
1368
|
+
targetAppId: registeredApp.targetAppId,
|
|
1369
|
+
targetAppSlug: registeredApp.targetAppSlug,
|
|
1370
|
+
manifest: registeredApp.manifest,
|
|
1371
|
+
}),
|
|
1372
|
+
};
|
|
1373
|
+
devServer.updateApp(app.devSlug, {
|
|
1374
|
+
appId: app.appId,
|
|
1375
|
+
targetAppId: app.targetAppId || null,
|
|
1376
|
+
});
|
|
1377
|
+
const now = new Date().toISOString();
|
|
1378
|
+
const sessionRecord = {
|
|
1379
|
+
sessionId,
|
|
1380
|
+
hostPid: process.pid,
|
|
1381
|
+
sourceHost: !sharedBundleBaseUrls,
|
|
1382
|
+
bundleReady: devServer.isBundleReady(app.devSlug),
|
|
1383
|
+
...(!sharedBundleBaseUrls ? {
|
|
1384
|
+
discoveredProjects: discoveredAppDirs,
|
|
1385
|
+
canonicalProjects: canonicalCandidates.map((candidate) => candidate.projectDir),
|
|
1386
|
+
} : {}),
|
|
1387
|
+
userId: identity,
|
|
1388
|
+
apiBase,
|
|
1389
|
+
profileKey,
|
|
1390
|
+
appId: app.appId,
|
|
1391
|
+
targetAppId: app.targetAppId || undefined,
|
|
1392
|
+
mountNonce: app.mountNonce,
|
|
1393
|
+
devSlug: app.devSlug,
|
|
1394
|
+
bundleBaseUrl: app.bundleBaseUrl,
|
|
1395
|
+
projectDir: app.projectDir,
|
|
1396
|
+
startedAt: now,
|
|
1397
|
+
lastHeartbeatAt: now,
|
|
1398
|
+
};
|
|
1399
|
+
upsertAppDevSessions(sessionRecord, sessionsFilePath);
|
|
1400
|
+
apps.push(app);
|
|
1401
|
+
if (!sessionRecord.bundleReady) {
|
|
1402
|
+
void devServer.waitForBundle(app.devSlug).then(() => {
|
|
1403
|
+
upsertAppDevSessions({
|
|
1404
|
+
...sessionRecord,
|
|
1405
|
+
bundleReady: true,
|
|
1406
|
+
lastHeartbeatAt: new Date().toISOString(),
|
|
1407
|
+
}, sessionsFilePath);
|
|
1408
|
+
}).catch((error) => {
|
|
1409
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1410
|
+
process.stderr.write(`[notis apps dev] ${app.devSlug}: initial bundle failed: ${message}\n`);
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
firstRegistrationError ||= error;
|
|
1415
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1416
|
+
registrationWarnings.push(
|
|
1417
|
+
`${projectDir} could not be mounted: ${message}. It will be retried when the shared host reconciles again.`,
|
|
1418
|
+
);
|
|
1419
|
+
process.stderr.write(`[notis apps dev] ${devSlug}: registration failed: ${message}\n`);
|
|
1420
|
+
} finally {
|
|
1421
|
+
logAppsTiming('ensure-dev-install', {
|
|
1422
|
+
slug: devSlug,
|
|
1423
|
+
ms: timingMs(appStartedAt).toFixed(1),
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (apps.length === 0) {
|
|
1428
|
+
clearInterval(heartbeatTimer);
|
|
1429
|
+
heartbeatTimer = null;
|
|
1430
|
+
try {
|
|
1431
|
+
await devServer.close();
|
|
1432
|
+
} catch {
|
|
1433
|
+
// preserve the original registration error
|
|
1434
|
+
}
|
|
1435
|
+
if (sourceHostLock) {
|
|
1436
|
+
releaseAppDevHostLock(sourceHostLock);
|
|
1437
|
+
sourceHostLock = null;
|
|
1438
|
+
}
|
|
1439
|
+
if (manualConsumerTimer) clearInterval(manualConsumerTimer);
|
|
1440
|
+
if (manualConsumerInstanceId) {
|
|
1441
|
+
try {
|
|
1442
|
+
removeAppDevConsumer(manualConsumerInstanceId);
|
|
1443
|
+
} catch {
|
|
1444
|
+
// Preserve the registration error; a failed lease cleanup expires.
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
throw firstRegistrationError || usageError('No unambiguous development apps could be mounted.');
|
|
1448
|
+
}
|
|
1449
|
+
logAppsTiming('ensure-dev-install:all', {
|
|
1450
|
+
apps: apps.length,
|
|
1451
|
+
ms: timingMs(registrationStartedAt).toFixed(1),
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
const warnings = [
|
|
1455
|
+
...canonicalSelection.warnings,
|
|
1456
|
+
...registrationWarnings,
|
|
1457
|
+
...databaseMaterializationWarnings(apps),
|
|
1458
|
+
...liveDataWarnings(apps),
|
|
1459
|
+
];
|
|
1460
|
+
|
|
1461
|
+
let consumerTimer = null;
|
|
938
1462
|
|
|
939
1463
|
ctx.output.emitSuccess({
|
|
940
1464
|
command: ctx.spec.command_path.join(' '),
|
|
941
1465
|
data: {
|
|
942
1466
|
mode,
|
|
943
1467
|
api_base: apiBase,
|
|
944
|
-
development_url: developmentTabUrl,
|
|
945
1468
|
session_id: sessionId,
|
|
946
1469
|
mount_status: 'serving',
|
|
947
|
-
|
|
948
|
-
desktop_target: desktopAppName,
|
|
1470
|
+
source_host: !sharedBundleBaseUrls,
|
|
949
1471
|
identity,
|
|
950
1472
|
apps: apps.map((app) => ({
|
|
951
1473
|
slug: app.devSlug,
|
|
@@ -958,78 +1480,28 @@ async function appsDevHandler(ctx) {
|
|
|
958
1480
|
created: app.created,
|
|
959
1481
|
linked_app_id: app.linkedAppId,
|
|
960
1482
|
database_materialization: app.databaseMaterialization,
|
|
1483
|
+
live_data: app.liveData,
|
|
961
1484
|
})),
|
|
962
1485
|
},
|
|
963
1486
|
warnings,
|
|
964
1487
|
humanSummary: [
|
|
965
1488
|
`Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
|
|
966
|
-
`
|
|
967
|
-
|
|
968
|
-
|
|
1489
|
+
...(!skipRootRegistration ? [`Registered development root: ${rootDir}`] : []),
|
|
1490
|
+
`Watching ${readAppDevRoots().roots.length} persistent development root(s).`,
|
|
1491
|
+
...(useInstalledDatabases
|
|
1492
|
+
? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
|
|
1493
|
+
: []),
|
|
969
1494
|
'',
|
|
970
1495
|
...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
|
|
971
1496
|
'',
|
|
972
|
-
|
|
1497
|
+
sharedBundleBaseUrls
|
|
1498
|
+
? `Mounted ${apps.length} shared source app${apps.length === 1 ? '' : 's'} for this authenticated environment.`
|
|
1499
|
+
: `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}.`,
|
|
973
1500
|
'',
|
|
974
1501
|
'Press Ctrl-C to stop.',
|
|
975
1502
|
].join('\n'),
|
|
976
1503
|
});
|
|
977
1504
|
|
|
978
|
-
if (shouldOpenDevelopmentTab(ctx.options)) {
|
|
979
|
-
openInBrowser(desktopWakeUrl, {
|
|
980
|
-
appName: desktopAppName,
|
|
981
|
-
bundleId: desktopBundleId,
|
|
982
|
-
scheme: desktopScheme,
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
void (async () => {
|
|
987
|
-
let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
|
|
988
|
-
sessionsFilePath,
|
|
989
|
-
});
|
|
990
|
-
if (!result.mounted) {
|
|
991
|
-
process.stderr.write(
|
|
992
|
-
`[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`,
|
|
993
|
-
);
|
|
994
|
-
}
|
|
995
|
-
while (!result.mounted) {
|
|
996
|
-
result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
|
|
997
|
-
sessionsFilePath,
|
|
998
|
-
timeoutMs: 60_000,
|
|
999
|
-
pollIntervalMs: 250,
|
|
1000
|
-
});
|
|
1001
|
-
}
|
|
1002
|
-
process.stderr.write(
|
|
1003
|
-
`[notis apps dev] Mounted in ${desktopAppName}: ${apps.map((app) => app.name).join(', ')}.\n`,
|
|
1004
|
-
);
|
|
1005
|
-
if (shouldOpenDevelopmentTab(ctx.options)) {
|
|
1006
|
-
openInBrowser(mountedDevelopmentTabUrl, {
|
|
1007
|
-
appName: desktopAppName,
|
|
1008
|
-
bundleId: desktopBundleId,
|
|
1009
|
-
scheme: desktopScheme,
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
const firstAppRender = expectedMountAcknowledgements.slice(0, 1);
|
|
1014
|
-
let renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
|
|
1015
|
-
sessionsFilePath,
|
|
1016
|
-
timeoutMs: 0,
|
|
1017
|
-
});
|
|
1018
|
-
while (!renderResult.mounted) {
|
|
1019
|
-
renderResult = await waitForAppDevSessionRenderAcknowledgements(firstAppRender, {
|
|
1020
|
-
sessionsFilePath,
|
|
1021
|
-
timeoutMs: 60_000,
|
|
1022
|
-
pollIntervalMs: 250,
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
process.stderr.write(
|
|
1026
|
-
`[notis apps dev] Rendered in ${desktopAppName}: ${apps[0].name}.\n`,
|
|
1027
|
-
);
|
|
1028
|
-
})().catch((error) => {
|
|
1029
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1030
|
-
process.stderr.write(`[notis apps dev] mount acknowledgement check failed: ${message}\n`);
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
1505
|
let shuttingDown = false;
|
|
1034
1506
|
const shutdown = async (signal) => {
|
|
1035
1507
|
if (shuttingDown) return;
|
|
@@ -1039,6 +1511,21 @@ async function appsDevHandler(ctx) {
|
|
|
1039
1511
|
clearInterval(heartbeatTimer);
|
|
1040
1512
|
heartbeatTimer = null;
|
|
1041
1513
|
}
|
|
1514
|
+
if (consumerTimer) {
|
|
1515
|
+
clearInterval(consumerTimer);
|
|
1516
|
+
consumerTimer = null;
|
|
1517
|
+
}
|
|
1518
|
+
if (manualConsumerTimer) {
|
|
1519
|
+
clearInterval(manualConsumerTimer);
|
|
1520
|
+
manualConsumerTimer = null;
|
|
1521
|
+
}
|
|
1522
|
+
if (manualConsumerInstanceId) {
|
|
1523
|
+
try {
|
|
1524
|
+
removeAppDevConsumer(manualConsumerInstanceId);
|
|
1525
|
+
} catch {
|
|
1526
|
+
// A crashed CLI lease expires automatically after the heartbeat window.
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1042
1529
|
try {
|
|
1043
1530
|
removeAppDevSession(sessionId, sessionsFilePath);
|
|
1044
1531
|
} catch {
|
|
@@ -1049,6 +1536,10 @@ async function appsDevHandler(ctx) {
|
|
|
1049
1536
|
} catch {
|
|
1050
1537
|
// ignore cleanup failures during shutdown
|
|
1051
1538
|
}
|
|
1539
|
+
if (sourceHostLock) {
|
|
1540
|
+
releaseAppDevHostLock(sourceHostLock);
|
|
1541
|
+
sourceHostLock = null;
|
|
1542
|
+
}
|
|
1052
1543
|
process.exit(EXIT_CODES.ok);
|
|
1053
1544
|
};
|
|
1054
1545
|
|
|
@@ -1059,11 +1550,53 @@ async function appsDevHandler(ctx) {
|
|
|
1059
1550
|
void shutdown('SIGTERM');
|
|
1060
1551
|
});
|
|
1061
1552
|
|
|
1553
|
+
if (consumerMode === 'machine' || consumerMode === 'environment') {
|
|
1554
|
+
consumerTimer = setInterval(() => {
|
|
1555
|
+
if (!hasAppDevConsumer(readAppDevConsumers(), {
|
|
1556
|
+
mode: consumerMode,
|
|
1557
|
+
userId: identity,
|
|
1558
|
+
apiBase,
|
|
1559
|
+
})) {
|
|
1560
|
+
void shutdown('last consumer detached');
|
|
1561
|
+
}
|
|
1562
|
+
}, DEV_CONSUMER_POLL_INTERVAL_MS);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1062
1565
|
await new Promise(() => {});
|
|
1063
1566
|
|
|
1064
1567
|
return EXIT_CODES.ok;
|
|
1065
1568
|
}
|
|
1066
1569
|
|
|
1570
|
+
async function appsRootsListHandler(ctx) {
|
|
1571
|
+
const registry = readAppDevRoots();
|
|
1572
|
+
return ctx.output.emitSuccess({
|
|
1573
|
+
command: ctx.spec.command_path.join(' '),
|
|
1574
|
+
data: { roots: registry.roots },
|
|
1575
|
+
humanSummary: appDevRootsTable(registry.roots),
|
|
1576
|
+
meta: { mutating: false },
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
async function appsRootsRemoveHandler(ctx) {
|
|
1581
|
+
const rootDir = resolve(ctx.args.dir);
|
|
1582
|
+
const result = removeAppDevRoot(rootDir);
|
|
1583
|
+
return ctx.output.emitSuccess({
|
|
1584
|
+
command: ctx.spec.command_path.join(' '),
|
|
1585
|
+
data: {
|
|
1586
|
+
removed: result.removed,
|
|
1587
|
+
root: result.path,
|
|
1588
|
+
roots: result.registry.roots,
|
|
1589
|
+
},
|
|
1590
|
+
humanSummary: result.removed
|
|
1591
|
+
? `Removed app development root: ${result.path}`
|
|
1592
|
+
: `App development root was not registered: ${result.path}`,
|
|
1593
|
+
hints: result.removed
|
|
1594
|
+
? []
|
|
1595
|
+
: [{ command: 'notis apps roots list', reason: 'Show registered roots' }],
|
|
1596
|
+
meta: { mutating: result.removed },
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1067
1600
|
async function appsBuildHandler(ctx) {
|
|
1068
1601
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1069
1602
|
const problems = detectProjectProblems(projectDir);
|
|
@@ -1105,7 +1638,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1105
1638
|
if (!ctx.runtime.jwt) {
|
|
1106
1639
|
throw usageError('Live verify mode requires CLI auth. Run notis login and retry.');
|
|
1107
1640
|
}
|
|
1108
|
-
linkedState = readLinkedState(projectDir);
|
|
1641
|
+
linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
|
|
1109
1642
|
if (!linkedState?.app_id) {
|
|
1110
1643
|
throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
|
|
1111
1644
|
}
|
|
@@ -1120,10 +1653,16 @@ async function appsVerifyHandler(ctx) {
|
|
|
1120
1653
|
const manifest = readManifest(projectDir);
|
|
1121
1654
|
const appConfig = await loadAppConfig(projectDir);
|
|
1122
1655
|
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
1123
|
-
|
|
1656
|
+
// Store readiness is a publish concern, not a render concern. Verify reports
|
|
1657
|
+
// it so the gaps stay visible while the app is still being built; only
|
|
1658
|
+
// --listing (and `apps publish`) turn it back into a hard gate.
|
|
1659
|
+
if (listing.errors.length && ctx.options.listing === true) {
|
|
1124
1660
|
throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
|
|
1125
1661
|
}
|
|
1126
|
-
const listingWarnings =
|
|
1662
|
+
const listingWarnings = [
|
|
1663
|
+
...[...listing.errors, ...listing.warnings].map((message) => `Store readiness: ${message}`),
|
|
1664
|
+
...findUnknownScreenshotScenarios(projectDir, resolveListingScreenshots(projectDir, appConfig)),
|
|
1665
|
+
];
|
|
1127
1666
|
const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
|
|
1128
1667
|
const port = parsePort(ctx.options.port) || await getAvailablePort();
|
|
1129
1668
|
const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
|
|
@@ -1195,6 +1734,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1195
1734
|
result,
|
|
1196
1735
|
route,
|
|
1197
1736
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1737
|
+
mode,
|
|
1198
1738
|
);
|
|
1199
1739
|
return {
|
|
1200
1740
|
...result,
|
|
@@ -1219,6 +1759,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1219
1759
|
result,
|
|
1220
1760
|
route,
|
|
1221
1761
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1762
|
+
mode,
|
|
1222
1763
|
);
|
|
1223
1764
|
results.push({
|
|
1224
1765
|
route: route.slug,
|
|
@@ -1259,6 +1800,11 @@ async function appsVerifyHandler(ctx) {
|
|
|
1259
1800
|
},
|
|
1260
1801
|
summary,
|
|
1261
1802
|
results,
|
|
1803
|
+
listing: {
|
|
1804
|
+
ready: listing.ready,
|
|
1805
|
+
gated: ctx.options.listing === true,
|
|
1806
|
+
problems: listing.errors,
|
|
1807
|
+
},
|
|
1262
1808
|
};
|
|
1263
1809
|
|
|
1264
1810
|
ctx.output.emitSuccess({
|
|
@@ -1325,7 +1871,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
1325
1871
|
if (!ctx.runtime.jwt) {
|
|
1326
1872
|
throw usageError('Live mode requires CLI auth. Run notis login and retry.');
|
|
1327
1873
|
}
|
|
1328
|
-
linkedState = readLinkedState(projectDir);
|
|
1874
|
+
linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
|
|
1329
1875
|
if (!linkedState?.app_id) {
|
|
1330
1876
|
throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
|
|
1331
1877
|
}
|
|
@@ -1360,6 +1906,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
1360
1906
|
const configuredScreenshots = resolveListingScreenshots(projectDir, appConfig)
|
|
1361
1907
|
.filter((screenshot) => screenshot.route)
|
|
1362
1908
|
.filter((screenshot) => !selectedRouteSlugs || selectedRouteSlugs.includes(screenshot.route));
|
|
1909
|
+
const scenarioWarnings = findUnknownScreenshotScenarios(projectDir, configuredScreenshots);
|
|
1363
1910
|
const routeBySlug = new Map(routes.map((route) => [route.slug, route]));
|
|
1364
1911
|
const captures = configuredScreenshots.length > 0
|
|
1365
1912
|
? configuredScreenshots.map((screenshot) => {
|
|
@@ -1471,7 +2018,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
1471
2018
|
|
|
1472
2019
|
const captured = results.filter((r) => r.ok);
|
|
1473
2020
|
const failed = results.filter((r) => !r.ok);
|
|
1474
|
-
const warnings = [];
|
|
2021
|
+
const warnings = [...scenarioWarnings];
|
|
1475
2022
|
|
|
1476
2023
|
// Drop stale screenshots only after a full refresh. A selected-route
|
|
1477
2024
|
// capture intentionally leaves other listing screenshots untouched.
|
|
@@ -1523,10 +2070,15 @@ async function appsScreenshotHandler(ctx) {
|
|
|
1523
2070
|
}
|
|
1524
2071
|
}
|
|
1525
2072
|
|
|
1526
|
-
export function buildLinkedAppState(
|
|
2073
|
+
export function buildLinkedAppState(
|
|
2074
|
+
existingState,
|
|
2075
|
+
appId,
|
|
2076
|
+
linkedAt = new Date().toISOString(),
|
|
2077
|
+
) {
|
|
2078
|
+
const sameIdPromotion = existingState?.dev_app_id === appId;
|
|
1527
2079
|
return {
|
|
1528
|
-
...(existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
|
|
1529
|
-
...(existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
|
|
2080
|
+
...(!sameIdPromotion && existingState?.dev_app_id ? { dev_app_id: existingState.dev_app_id } : {}),
|
|
2081
|
+
...(!sameIdPromotion && existingState?.dev_linked_at ? { dev_linked_at: existingState.dev_linked_at } : {}),
|
|
1530
2082
|
app_id: appId,
|
|
1531
2083
|
linked_at: linkedAt,
|
|
1532
2084
|
};
|
|
@@ -1540,7 +2092,12 @@ async function appsLinkHandler(ctx) {
|
|
|
1540
2092
|
|
|
1541
2093
|
writeLinkedState(
|
|
1542
2094
|
projectDir,
|
|
1543
|
-
buildLinkedAppState(
|
|
2095
|
+
buildLinkedAppState(
|
|
2096
|
+
readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime)),
|
|
2097
|
+
appId,
|
|
2098
|
+
new Date().toISOString(),
|
|
2099
|
+
),
|
|
2100
|
+
linkedStateProfileKey(ctx.runtime),
|
|
1544
2101
|
);
|
|
1545
2102
|
|
|
1546
2103
|
return ctx.output.emitSuccess({
|
|
@@ -1567,9 +2124,11 @@ async function appsPullHandler(ctx) {
|
|
|
1567
2124
|
throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
|
|
1568
2125
|
}
|
|
1569
2126
|
const app = result.payload?.app || {};
|
|
1570
|
-
const defaultDir = app.slug || slugify(app.name) || appId;
|
|
1571
|
-
const targetDir =
|
|
1572
|
-
|
|
2127
|
+
const defaultDir = slugify(app.slug) || slugify(app.name) || slugify(appId);
|
|
2128
|
+
const targetDir = ctx.args.dir
|
|
2129
|
+
? resolveProjectDir(ctx.args.dir)
|
|
2130
|
+
: defaultAppProjectDir(defaultDir);
|
|
2131
|
+
const version = ctx.options.sourceVersion || 'latest';
|
|
1573
2132
|
|
|
1574
2133
|
const pulled = await pullAppSource({
|
|
1575
2134
|
apiBase: ctx.runtime.apiBase,
|
|
@@ -1578,6 +2137,7 @@ async function appsPullHandler(ctx) {
|
|
|
1578
2137
|
targetDir,
|
|
1579
2138
|
version,
|
|
1580
2139
|
force: Boolean(ctx.options.force),
|
|
2140
|
+
profileKey: linkedStateProfileKey(ctx.runtime),
|
|
1581
2141
|
});
|
|
1582
2142
|
|
|
1583
2143
|
const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
|
|
@@ -1592,7 +2152,7 @@ async function appsPullHandler(ctx) {
|
|
|
1592
2152
|
});
|
|
1593
2153
|
}
|
|
1594
2154
|
|
|
1595
|
-
function updateLinkedDeployState(projectDir, linkedState, appId, version) {
|
|
2155
|
+
function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null) {
|
|
1596
2156
|
if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
|
|
1597
2157
|
return;
|
|
1598
2158
|
}
|
|
@@ -1602,14 +2162,29 @@ function updateLinkedDeployState(projectDir, linkedState, appId, version) {
|
|
|
1602
2162
|
version,
|
|
1603
2163
|
linked_at: linkedState.linked_at || new Date().toISOString(),
|
|
1604
2164
|
deployed_at: new Date().toISOString(),
|
|
1605
|
-
});
|
|
2165
|
+
}, profileKey);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// Resolve first deploy without mutating. Promotion is part of the final
|
|
2169
|
+
// SAVE_APP_FILES update, after build and uploads succeed, so a failed deploy
|
|
2170
|
+
// cannot leave an empty installed app behind.
|
|
2171
|
+
function resolveDeployTarget(ctx, projectDir) {
|
|
2172
|
+
if (ctx.options.appId) return { appId: ctx.options.appId, needsPromotion: false };
|
|
2173
|
+
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
2174
|
+
const state = readLinkedState(projectDir, profileKey);
|
|
2175
|
+
if (state?.app_id) return { appId: state.app_id, needsPromotion: false };
|
|
2176
|
+
if (!state?.dev_app_id) {
|
|
2177
|
+
return { appId: requireLinkedAppId(projectDir, ctx.options.appId, profileKey), needsPromotion: false };
|
|
2178
|
+
}
|
|
2179
|
+
return { appId: state.dev_app_id, needsPromotion: true };
|
|
1606
2180
|
}
|
|
1607
2181
|
|
|
1608
2182
|
async function appsDeployHandler(ctx) {
|
|
1609
2183
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1610
|
-
const
|
|
2184
|
+
const profileKey = linkedStateProfileKey(ctx.runtime);
|
|
2185
|
+
const { appId, needsPromotion } = resolveDeployTarget(ctx, projectDir);
|
|
1611
2186
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
1612
|
-
const linkedState = readLinkedState(projectDir);
|
|
2187
|
+
const linkedState = readLinkedState(projectDir, profileKey);
|
|
1613
2188
|
const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
|
|
1614
2189
|
? linkedState.version
|
|
1615
2190
|
: undefined;
|
|
@@ -1623,9 +2198,12 @@ async function appsDeployHandler(ctx) {
|
|
|
1623
2198
|
|
|
1624
2199
|
// Direct deploy mode: upload to Supabase storage directly
|
|
1625
2200
|
if (ctx.options.direct) {
|
|
2201
|
+
if (needsPromotion) {
|
|
2202
|
+
throw usageError('The first deploy must use the backend so promotion and save are atomic. Retry without --direct.');
|
|
2203
|
+
}
|
|
1626
2204
|
await assertDirectDeployAccess(ctx.runtime, appId);
|
|
1627
2205
|
const { version } = await directDeploy(projectDir, appId);
|
|
1628
|
-
updateLinkedDeployState(projectDir, linkedState, appId, version);
|
|
2206
|
+
updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey);
|
|
1629
2207
|
return ctx.output.emitSuccess({
|
|
1630
2208
|
command: ctx.spec.command_path.join(' '),
|
|
1631
2209
|
data: { app_id: appId, version, mode: 'direct' },
|
|
@@ -1642,7 +2220,14 @@ async function appsDeployHandler(ctx) {
|
|
|
1642
2220
|
let result;
|
|
1643
2221
|
try {
|
|
1644
2222
|
result = await runToolCommand({
|
|
1645
|
-
|
|
2223
|
+
// App deploys upload both the built artifact and the editable source
|
|
2224
|
+
// snapshot. The ordinary 30s CLI timeout is too short for larger apps,
|
|
2225
|
+
// and timing out a mutation is ambiguous: the backend may commit after
|
|
2226
|
+
// the client disconnects. Give this operation its real completion window.
|
|
2227
|
+
runtime: {
|
|
2228
|
+
...ctx.runtime,
|
|
2229
|
+
timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
|
|
2230
|
+
},
|
|
1646
2231
|
toolName: SAVE_APP_FILES_TOOL,
|
|
1647
2232
|
arguments_: {
|
|
1648
2233
|
app_id: appId,
|
|
@@ -1651,6 +2236,7 @@ async function appsDeployHandler(ctx) {
|
|
|
1651
2236
|
manifest,
|
|
1652
2237
|
...appRowFieldsFromManifest(manifest),
|
|
1653
2238
|
...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
|
|
2239
|
+
...(needsPromotion ? { promote_dev_app: true } : {}),
|
|
1654
2240
|
},
|
|
1655
2241
|
mutating: true,
|
|
1656
2242
|
idempotencyKey,
|
|
@@ -1660,51 +2246,75 @@ async function appsDeployHandler(ctx) {
|
|
|
1660
2246
|
throw toolConflictToError(error.details, 'Deploy conflict');
|
|
1661
2247
|
}
|
|
1662
2248
|
|
|
1663
|
-
//
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
await assertDirectDeployAccess(ctx.runtime, appId);
|
|
1671
|
-
} catch (accessError) {
|
|
1672
|
-
throw usageError(
|
|
1673
|
-
`Backend deploy failed (${error.message}) and direct fallback was blocked because app access ` +
|
|
1674
|
-
`could not be verified (${accessError.message}).`,
|
|
1675
|
-
);
|
|
1676
|
-
}
|
|
2249
|
+
// A timed-out mutation may already have committed on the backend. A direct
|
|
2250
|
+
// upload here would create a second revision, so fail closed and let the
|
|
2251
|
+
// caller inspect/pull the app before deciding whether to retry.
|
|
2252
|
+
if (error.code === 'network_timeout') {
|
|
2253
|
+
error.message = `${error.message}. The backend may still complete this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying if needed.`;
|
|
2254
|
+
throw error;
|
|
2255
|
+
}
|
|
1677
2256
|
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
});
|
|
1688
|
-
} catch (directError) {
|
|
1689
|
-
throw usageError(
|
|
1690
|
-
`Backend deploy failed (${error.message}) and direct fallback also failed (${directError.message}). ` +
|
|
1691
|
-
'Check server/.env for Supabase credentials or start the backend server.',
|
|
1692
|
-
);
|
|
1693
|
-
}
|
|
2257
|
+
// A network failure is ambiguous for a mutation. It can happen after the
|
|
2258
|
+
// backend committed but before undici finished reading the response (for
|
|
2259
|
+
// example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
|
|
2260
|
+
// generic network_error or its message: an automatic direct upload could
|
|
2261
|
+
// create a second revision. Operators who have proved the backend is down
|
|
2262
|
+
// can still choose the explicit --direct mode.
|
|
2263
|
+
if (error.code === 'network_error') {
|
|
2264
|
+
error.message = `${error.message}. The backend may have committed this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying, or use --direct only after proving the backend mutation did not land.`;
|
|
2265
|
+
throw error;
|
|
1694
2266
|
}
|
|
1695
2267
|
|
|
1696
2268
|
throw error;
|
|
1697
2269
|
}
|
|
1698
2270
|
|
|
1699
|
-
|
|
2271
|
+
const deployedVersion = Number(result?.payload?.version);
|
|
2272
|
+
if (!Number.isFinite(deployedVersion) || deployedVersion <= 0) {
|
|
2273
|
+
throw new CliError({
|
|
2274
|
+
code: 'network_error',
|
|
2275
|
+
message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying. Direct fallback was not attempted to avoid creating a duplicate revision.',
|
|
2276
|
+
exitCode: EXIT_CODES.network,
|
|
2277
|
+
retryable: true,
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
if (needsPromotion) {
|
|
2282
|
+
const now = new Date().toISOString();
|
|
2283
|
+
writeLinkedState(projectDir, {
|
|
2284
|
+
...linkedState,
|
|
2285
|
+
app_id: appId,
|
|
2286
|
+
linked_at: linkedState?.linked_at || now,
|
|
2287
|
+
version: deployedVersion,
|
|
2288
|
+
deployed_at: now,
|
|
2289
|
+
dev_app_id: undefined,
|
|
2290
|
+
dev_linked_at: undefined,
|
|
2291
|
+
}, profileKey);
|
|
2292
|
+
try {
|
|
2293
|
+
linkAppDevSessionTarget(
|
|
2294
|
+
{ appId, targetAppId: appId },
|
|
2295
|
+
getAppDevSessionsFile(),
|
|
2296
|
+
);
|
|
2297
|
+
} catch (error) {
|
|
2298
|
+
ctx.output.emitProgress({
|
|
2299
|
+
phase: 'warning',
|
|
2300
|
+
message: `Deploy succeeded, but the local development session could not be retargeted: ${error.message}`,
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
ctx.output.emitProgress({
|
|
2304
|
+
phase: 'promote',
|
|
2305
|
+
message: `Promoted development app to installed app ${appId}`,
|
|
2306
|
+
});
|
|
2307
|
+
} else {
|
|
2308
|
+
updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey);
|
|
2309
|
+
}
|
|
1700
2310
|
return ctx.output.emitSuccess({
|
|
1701
2311
|
command: ctx.spec.command_path.join(' '),
|
|
1702
2312
|
data: {
|
|
1703
2313
|
app_id: appId,
|
|
1704
|
-
version:
|
|
2314
|
+
version: deployedVersion,
|
|
1705
2315
|
idempotency_key: idempotencyKey,
|
|
1706
2316
|
},
|
|
1707
|
-
humanSummary: `Deployed to app ${appId} (version ${
|
|
2317
|
+
humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
|
|
1708
2318
|
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
1709
2319
|
});
|
|
1710
2320
|
}
|
|
@@ -1719,7 +2329,7 @@ async function appsDuplicateHandler(ctx) {
|
|
|
1719
2329
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1720
2330
|
// Either target an app explicitly, or duplicate whatever this project is
|
|
1721
2331
|
// linked to, so `notis apps duplicate` works from inside a project.
|
|
1722
|
-
const appId = requireLinkedAppId(projectDir, ctx.options.appId);
|
|
2332
|
+
const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
|
|
1723
2333
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
1724
2334
|
|
|
1725
2335
|
const copyDocuments = ctx.options.copyDocuments || 'declared';
|
|
@@ -1785,8 +2395,8 @@ async function appsPublishHandler(ctx) {
|
|
|
1785
2395
|
}
|
|
1786
2396
|
|
|
1787
2397
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1788
|
-
const appId = requireLinkedAppId(projectDir, ctx.options.appId);
|
|
1789
|
-
const linkedState = readLinkedState(projectDir);
|
|
2398
|
+
const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
|
|
2399
|
+
const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
|
|
1790
2400
|
const appConfig = await loadAppConfig(projectDir);
|
|
1791
2401
|
const readiness = inspectListingReadiness(projectDir, appConfig);
|
|
1792
2402
|
if (!readiness.ready) {
|
|
@@ -1879,7 +2489,7 @@ async function appsDoctorHandler(ctx) {
|
|
|
1879
2489
|
}
|
|
1880
2490
|
}
|
|
1881
2491
|
|
|
1882
|
-
const linkedState = readLinkedState(projectDir);
|
|
2492
|
+
const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
|
|
1883
2493
|
const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
|
|
1884
2494
|
|
|
1885
2495
|
return ctx.output.emitSuccess({
|
|
@@ -1922,21 +2532,21 @@ export const appsCommandSpecs = [
|
|
|
1922
2532
|
{
|
|
1923
2533
|
command_path: ['apps', 'init'],
|
|
1924
2534
|
summary: 'Scaffold a new Notis app project.',
|
|
1925
|
-
when_to_use: 'Start a new Notis app. Use --from with a
|
|
2535
|
+
when_to_use: 'Start a new Notis app. Use --from with a published Store app when one is close to the desired app; otherwise creates the bare Vite + React project.',
|
|
1926
2536
|
args_schema: {
|
|
1927
2537
|
arguments: [
|
|
1928
2538
|
{ token: '<name>', description: 'Display name for the app.' },
|
|
1929
|
-
{ token: '[dir]', key: 'dir', description: 'Target directory
|
|
2539
|
+
{ token: '[dir]', key: 'dir', description: 'Target directory, resolved from the current directory. Defaults to ~/.notis/apps/<slug>; pass a path to place the project elsewhere, such as a tracked git repo or an existing monorepo.' },
|
|
1930
2540
|
],
|
|
1931
2541
|
options: [
|
|
1932
|
-
{ flags: '--from <slug>', description: 'Start from a
|
|
2542
|
+
{ flags: '--from <slug>', description: 'Start from a published Store app listed by `notis apps scaffolds list`. Downloads its source from the public app registry.' },
|
|
1933
2543
|
],
|
|
1934
2544
|
},
|
|
1935
2545
|
examples: [
|
|
1936
2546
|
'notis apps scaffolds list',
|
|
1937
2547
|
'notis apps init "Mind the Flo"',
|
|
1938
|
-
'notis apps init "My CRM" --from
|
|
1939
|
-
'notis apps init "My App"
|
|
2548
|
+
'notis apps init "My CRM" --from databases',
|
|
2549
|
+
'notis apps init "My App" ~/code/my-app',
|
|
1940
2550
|
],
|
|
1941
2551
|
mutates: true,
|
|
1942
2552
|
idempotent: false,
|
|
@@ -1946,10 +2556,19 @@ export const appsCommandSpecs = [
|
|
|
1946
2556
|
},
|
|
1947
2557
|
{
|
|
1948
2558
|
command_path: ['apps', 'scaffolds', 'list'],
|
|
1949
|
-
summary: 'List
|
|
1950
|
-
when_to_use: 'Discover
|
|
1951
|
-
args_schema: {
|
|
1952
|
-
|
|
2559
|
+
summary: 'List published Store apps available as scaffolds.',
|
|
2560
|
+
when_to_use: 'Discover published Store apps to start from before creating a new app. Every app published to the public Store is automatically a scaffold; use --search to narrow the catalog.',
|
|
2561
|
+
args_schema: {
|
|
2562
|
+
arguments: [],
|
|
2563
|
+
options: [
|
|
2564
|
+
{ flags: '--search <term>', description: 'Filter scaffolds by name, tagline, description, or category.' },
|
|
2565
|
+
],
|
|
2566
|
+
},
|
|
2567
|
+
examples: [
|
|
2568
|
+
'notis apps scaffolds list',
|
|
2569
|
+
'notis apps scaffolds list --search journal',
|
|
2570
|
+
'notis apps init "My App" --from databases',
|
|
2571
|
+
],
|
|
1953
2572
|
mutates: false,
|
|
1954
2573
|
idempotent: true,
|
|
1955
2574
|
require_auth: false,
|
|
@@ -1978,22 +2597,40 @@ export const appsCommandSpecs = [
|
|
|
1978
2597
|
},
|
|
1979
2598
|
{
|
|
1980
2599
|
command_path: ['apps', 'dev'],
|
|
1981
|
-
summary: '
|
|
2600
|
+
summary: 'Register a development root and connect its apps to the shared local development host.',
|
|
1982
2601
|
when_to_use:
|
|
1983
|
-
'Run this
|
|
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.',
|
|
1984
2603
|
args_schema: {
|
|
1985
2604
|
arguments: [
|
|
1986
2605
|
{ token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
|
|
1987
2606
|
],
|
|
1988
2607
|
options: [
|
|
1989
2608
|
{ flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
|
|
1990
|
-
{
|
|
2609
|
+
{
|
|
2610
|
+
flags: '--scratch',
|
|
2611
|
+
description:
|
|
2612
|
+
'Use isolated empty databases, bundled skills, and bundled automations for this session '
|
|
2613
|
+
+ "instead of the installed app's resources. For fixture work and destructive experiments.",
|
|
2614
|
+
},
|
|
2615
|
+
{
|
|
2616
|
+
flags: '--live-data',
|
|
2617
|
+
description:
|
|
2618
|
+
'Deprecated: using the installed app\'s real resources is now the default. '
|
|
2619
|
+
+ 'Accepted as a no-op; use `--scratch` for the old isolated behavior.',
|
|
2620
|
+
},
|
|
2621
|
+
{
|
|
2622
|
+
flags: '--grant-cloud-shell',
|
|
2623
|
+
description:
|
|
2624
|
+
"Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
|
|
2625
|
+
+ 'The grant persists for this dev app; authorship alone never grants it.',
|
|
2626
|
+
},
|
|
1991
2627
|
],
|
|
1992
2628
|
},
|
|
1993
2629
|
examples: [
|
|
1994
2630
|
'notis apps dev',
|
|
1995
2631
|
'notis apps dev ./my-app',
|
|
1996
2632
|
'notis apps dev ./workspace --port 5200',
|
|
2633
|
+
'notis apps dev --scratch # isolated resources for fixture or schema experiments',
|
|
1997
2634
|
],
|
|
1998
2635
|
mutates: true,
|
|
1999
2636
|
idempotent: true,
|
|
@@ -2001,6 +2638,35 @@ export const appsCommandSpecs = [
|
|
|
2001
2638
|
backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
|
|
2002
2639
|
handler: appsDevHandler,
|
|
2003
2640
|
},
|
|
2641
|
+
{
|
|
2642
|
+
command_path: ['apps', 'roots', 'list'],
|
|
2643
|
+
summary: 'List persistent machine-local Notis app development roots.',
|
|
2644
|
+
when_to_use: 'See which folders every local Notis Desktop instance watches for development apps.',
|
|
2645
|
+
args_schema: { arguments: [], options: [] },
|
|
2646
|
+
examples: ['notis apps roots list'],
|
|
2647
|
+
mutates: false,
|
|
2648
|
+
idempotent: true,
|
|
2649
|
+
require_auth: false,
|
|
2650
|
+
backend_call: { type: 'local', name: 'list_app_development_roots' },
|
|
2651
|
+
handler: appsRootsListHandler,
|
|
2652
|
+
},
|
|
2653
|
+
{
|
|
2654
|
+
command_path: ['apps', 'roots', 'remove'],
|
|
2655
|
+
summary: 'Stop watching a registered Notis app development root.',
|
|
2656
|
+
when_to_use: 'Remove a persistent development root. The built-in ~/.notis/apps root cannot be removed.',
|
|
2657
|
+
args_schema: {
|
|
2658
|
+
arguments: [
|
|
2659
|
+
{ token: '<folder>', key: 'dir', description: 'Previously registered development root.' },
|
|
2660
|
+
],
|
|
2661
|
+
options: [],
|
|
2662
|
+
},
|
|
2663
|
+
examples: ['notis apps roots remove ./old-apps'],
|
|
2664
|
+
mutates: true,
|
|
2665
|
+
idempotent: true,
|
|
2666
|
+
require_auth: false,
|
|
2667
|
+
backend_call: { type: 'local', name: 'remove_app_development_root' },
|
|
2668
|
+
handler: appsRootsRemoveHandler,
|
|
2669
|
+
},
|
|
2004
2670
|
{
|
|
2005
2671
|
command_path: ['apps', 'build'],
|
|
2006
2672
|
summary: 'Build and package the app into .notis/output/.',
|
|
@@ -2020,10 +2686,11 @@ export const appsCommandSpecs = [
|
|
|
2020
2686
|
},
|
|
2021
2687
|
{
|
|
2022
2688
|
command_path: ['apps', 'verify'],
|
|
2023
|
-
summary: 'Validate every route and
|
|
2689
|
+
summary: 'Validate that every route renders and reports Store listing readiness.',
|
|
2024
2690
|
when_to_use:
|
|
2025
|
-
'
|
|
2026
|
-
'missing runtime calls
|
|
2691
|
+
'Any time after notis apps build, and before deploy. Catches render-time crashes and ' +
|
|
2692
|
+
'missing runtime calls. Incomplete listing media is reported as a warning; pass --listing ' +
|
|
2693
|
+
'to fail on it instead.',
|
|
2027
2694
|
args_schema: {
|
|
2028
2695
|
arguments: [
|
|
2029
2696
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
@@ -2032,7 +2699,8 @@ export const appsCommandSpecs = [
|
|
|
2032
2699
|
{ flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
|
|
2033
2700
|
{ flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
|
|
2034
2701
|
{ flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
|
|
2035
|
-
{ flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT.' },
|
|
2702
|
+
{ flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT and fails routes whose runtime calls all errored.' },
|
|
2703
|
+
{ flags: '--listing', description: 'Fail instead of warn when the Store listing (tagline, categories, screenshots, changelog) is incomplete.' },
|
|
2036
2704
|
{ flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
|
|
2037
2705
|
{ flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
|
|
2038
2706
|
],
|
|
@@ -2041,6 +2709,7 @@ export const appsCommandSpecs = [
|
|
|
2041
2709
|
'notis apps verify',
|
|
2042
2710
|
'notis apps verify --routes notes',
|
|
2043
2711
|
'notis apps verify --mode live',
|
|
2712
|
+
'notis apps verify --listing # gate on Store listing readiness before publish',
|
|
2044
2713
|
'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
|
|
2045
2714
|
],
|
|
2046
2715
|
mutates: false,
|
|
@@ -2110,14 +2779,17 @@ export const appsCommandSpecs = [
|
|
|
2110
2779
|
args_schema: {
|
|
2111
2780
|
arguments: [
|
|
2112
2781
|
{ token: '<app-id>', description: 'Remote app ID to pull.' },
|
|
2113
|
-
{ token: '[dir]', key: 'dir', description: 'Target directory (defaults to
|
|
2782
|
+
{ token: '[dir]', key: 'dir', description: 'Target directory (defaults to ~/.notis/apps/<app-slug>).' },
|
|
2114
2783
|
],
|
|
2115
2784
|
options: [
|
|
2116
2785
|
{ flags: '--force', description: 'Overwrite a non-empty target directory.' },
|
|
2117
|
-
{ flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
|
|
2786
|
+
{ flags: '--source-version <n>', description: 'Pull a specific app source version (default: latest).' },
|
|
2118
2787
|
],
|
|
2119
2788
|
},
|
|
2120
|
-
examples: [
|
|
2789
|
+
examples: [
|
|
2790
|
+
'notis apps pull abc123',
|
|
2791
|
+
'notis apps pull abc123 ./my-app --force --source-version 3',
|
|
2792
|
+
],
|
|
2121
2793
|
mutates: true,
|
|
2122
2794
|
idempotent: true,
|
|
2123
2795
|
require_auth: true,
|
|
@@ -2128,7 +2800,7 @@ export const appsCommandSpecs = [
|
|
|
2128
2800
|
command_path: ['apps', 'deploy'],
|
|
2129
2801
|
summary: 'Build and upload the app to the linked Notis app.',
|
|
2130
2802
|
when_to_use:
|
|
2131
|
-
'Ship the installed app to production for the linked user/team app.
|
|
2803
|
+
'Ship the installed app to production for the linked user/team app. A project that has only a development app is promoted in place on first deploy: same app id, same databases, dev markers removed. This command does not publish to the app store.',
|
|
2132
2804
|
args_schema: {
|
|
2133
2805
|
arguments: [
|
|
2134
2806
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
@@ -2136,7 +2808,7 @@ export const appsCommandSpecs = [
|
|
|
2136
2808
|
options: [
|
|
2137
2809
|
{ flags: '--app-id <id>', description: 'Override linked app ID.' },
|
|
2138
2810
|
{ flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
|
|
2139
|
-
{ flags: '--direct', description: '
|
|
2811
|
+
{ flags: '--direct', description: 'Explicitly upload to Supabase storage, bypassing the backend server.' },
|
|
2140
2812
|
],
|
|
2141
2813
|
},
|
|
2142
2814
|
examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
|