@notis_ai/cli 0.2.13 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -3
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +2 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +132 -1
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +40 -2
- package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +21 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +17 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +132 -1
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +11 -7
- package/skills/notis-apps/cli.md +8 -3
- package/src/command-specs/apps.js +238 -50
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/index.js +3 -0
- package/src/command-specs/tools.js +6 -0
- package/src/runtime/app-dev-server.js +17 -8
- package/src/runtime/app-platform.js +218 -6
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/transport.js +19 -2
- package/template/.harness/index.html.tmpl +116 -47
- package/template/packages/sdk/src/config.ts +52 -0
- package/template/packages/sdk/src/documents.ts +21 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +75 -0
- package/template/packages/sdk/src/index.ts +17 -0
- package/template/packages/sdk/src/runtime.ts +132 -1
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
|
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs
|
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { basename, join, relative, resolve } from 'node:path';
|
|
15
15
|
|
|
16
|
-
import { EXIT_CODES, usageError } from '../runtime/errors.js';
|
|
16
|
+
import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
|
|
17
17
|
import { formatTable } from '../runtime/output.js';
|
|
18
18
|
import {
|
|
19
19
|
resolveProjectDir,
|
|
@@ -27,12 +27,14 @@ import {
|
|
|
27
27
|
requireLinkedAppId,
|
|
28
28
|
scaffoldProject,
|
|
29
29
|
loadScaffoldCatalog,
|
|
30
|
+
findUnknownScreenshotScenarios,
|
|
30
31
|
inspectListingReadiness,
|
|
31
32
|
resolveListingScreenshots,
|
|
32
33
|
collectArtifactFiles,
|
|
33
34
|
collectSourceFiles,
|
|
34
35
|
resolveConfiguredAppSkills,
|
|
35
36
|
normalizeAppCapabilities,
|
|
37
|
+
normalizeAppSkillManifestPath,
|
|
36
38
|
directDeploy,
|
|
37
39
|
pullAppSource,
|
|
38
40
|
} from '../runtime/app-platform.js';
|
|
@@ -72,6 +74,7 @@ const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
|
|
|
72
74
|
const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
|
|
73
75
|
const DUPLICATE_APP_TOOL = 'LOCAL_NOTIS_DUPLICATE_APP';
|
|
74
76
|
const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
|
|
77
|
+
const APP_DEPLOY_TIMEOUT_MS = 120_000;
|
|
75
78
|
|
|
76
79
|
// ---------------------------------------------------------------------------
|
|
77
80
|
// Formatters
|
|
@@ -404,7 +407,17 @@ function harnessErrorMessage(error) {
|
|
|
404
407
|
return error.message || error.reason || error.type || JSON.stringify(error);
|
|
405
408
|
}
|
|
406
409
|
|
|
407
|
-
function
|
|
410
|
+
function runtimeCallLabel(call) {
|
|
411
|
+
if (call?.op === 'callTool') {
|
|
412
|
+
return call?.args?.name || 'callTool';
|
|
413
|
+
}
|
|
414
|
+
if (call?.op === 'request') {
|
|
415
|
+
return `request ${call?.args?.path || ''}`.trim();
|
|
416
|
+
}
|
|
417
|
+
return call?.op || 'runtime call';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
|
|
408
421
|
const assertions = [];
|
|
409
422
|
if (result.tool_error) {
|
|
410
423
|
assertions.push({
|
|
@@ -436,8 +449,9 @@ function assertHarnessResult(result, route, databaseSlugs) {
|
|
|
436
449
|
details: error,
|
|
437
450
|
});
|
|
438
451
|
}
|
|
452
|
+
const runtimeCalls = result.runtimeCalls || [];
|
|
439
453
|
const declaredDatabaseSet = new Set(databaseSlugs);
|
|
440
|
-
const databaseQueries =
|
|
454
|
+
const databaseQueries = runtimeCalls.filter(
|
|
441
455
|
(call) =>
|
|
442
456
|
call?.op === 'callTool'
|
|
443
457
|
&& localNotisToolSlug(call?.args?.name) === 'LOCAL_NOTIS_DATABASE_QUERY',
|
|
@@ -465,6 +479,36 @@ function assertHarnessResult(result, route, databaseSlugs) {
|
|
|
465
479
|
details: { databaseSlug: collectionDatabase },
|
|
466
480
|
});
|
|
467
481
|
}
|
|
482
|
+
if (mode === 'live') {
|
|
483
|
+
// In live mode an app that catches every failed call and renders its error
|
|
484
|
+
// state still mounts cleanly, so the render assertions above all pass. Only
|
|
485
|
+
// the recorded outcomes reveal that nothing real came back.
|
|
486
|
+
if (runtimeCalls.length > 0 && runtimeCalls.every((call) => call?.ok === false)) {
|
|
487
|
+
assertions.push({
|
|
488
|
+
ok: false,
|
|
489
|
+
code: 'all_runtime_calls_failed',
|
|
490
|
+
message: `Route "${route.slug}" rendered without data: all ${runtimeCalls.length} runtime call(s) failed. First error: ${runtimeCalls[0]?.error || 'unknown'}.`,
|
|
491
|
+
details: {
|
|
492
|
+
failed: runtimeCalls.map((call) => ({ call: runtimeCallLabel(call), error: call?.error || null })),
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
for (const databaseSlug of databaseSlugs) {
|
|
497
|
+
const queries = databaseQueries.filter(
|
|
498
|
+
(call) => call?.args?.arguments?.database_slug === databaseSlug,
|
|
499
|
+
);
|
|
500
|
+
// A call still in flight when the harness was read has ok === null; only
|
|
501
|
+
// an explicit failure with no successful sibling is a real problem.
|
|
502
|
+
if (queries.some((call) => call?.ok === false) && !queries.some((call) => call?.ok === true)) {
|
|
503
|
+
assertions.push({
|
|
504
|
+
ok: false,
|
|
505
|
+
code: 'failed_database_query',
|
|
506
|
+
message: `Route "${route.slug}" never got a successful "${databaseSlug}" query. Last error: ${queries[queries.length - 1]?.error || 'unknown'}.`,
|
|
507
|
+
details: { databaseSlug },
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
468
512
|
return assertions;
|
|
469
513
|
}
|
|
470
514
|
|
|
@@ -479,6 +523,9 @@ function renderVerifyReport({ summary, results, noBrowser }) {
|
|
|
479
523
|
lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
|
|
480
524
|
for (const assertion of result.assertions || []) {
|
|
481
525
|
lines.push(` - ${assertion.message}`);
|
|
526
|
+
for (const failure of assertion.details?.failed || []) {
|
|
527
|
+
lines.push(` ${failure.call}: ${failure.error || 'unknown error'}`);
|
|
528
|
+
}
|
|
482
529
|
}
|
|
483
530
|
}
|
|
484
531
|
if (noBrowser) {
|
|
@@ -516,7 +563,7 @@ function buildManifestForDev(appConfig) {
|
|
|
516
563
|
tools: appConfig.tools || [],
|
|
517
564
|
skills: (appConfig.skills || []).map((skill) => ({
|
|
518
565
|
key: skill.key,
|
|
519
|
-
path:
|
|
566
|
+
path: normalizeAppSkillManifestPath(skill.path),
|
|
520
567
|
name: skill.name,
|
|
521
568
|
description: skill.description || null,
|
|
522
569
|
})),
|
|
@@ -524,7 +571,14 @@ function buildManifestForDev(appConfig) {
|
|
|
524
571
|
};
|
|
525
572
|
}
|
|
526
573
|
|
|
527
|
-
export function buildEnsureDevInstallArguments({
|
|
574
|
+
export function buildEnsureDevInstallArguments({
|
|
575
|
+
appConfig,
|
|
576
|
+
manifest,
|
|
577
|
+
linkedState,
|
|
578
|
+
skills,
|
|
579
|
+
useInstalledDatabases = false,
|
|
580
|
+
approvedCapabilities = null,
|
|
581
|
+
}) {
|
|
528
582
|
const devSlug = buildDevInstallSlug(appConfig.name);
|
|
529
583
|
const arguments_ = {
|
|
530
584
|
dev_slug: devSlug,
|
|
@@ -535,14 +589,87 @@ export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedStat
|
|
|
535
589
|
if (linkedState?.dev_app_id) {
|
|
536
590
|
arguments_.app_id = linkedState.dev_app_id;
|
|
537
591
|
}
|
|
592
|
+
// Sent on every ensure call, including as `false`, so dropping `--live-data`
|
|
593
|
+
// puts the next session back on its own dev copies.
|
|
594
|
+
arguments_.use_installed_databases = Boolean(useInstalledDatabases);
|
|
595
|
+
if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
|
|
596
|
+
arguments_.approved_capabilities = approvedCapabilities;
|
|
597
|
+
}
|
|
538
598
|
return arguments_;
|
|
539
599
|
}
|
|
540
600
|
|
|
601
|
+
const CLOUD_SHELL_CONSENT_KEY = 'cloud_computer_shell_consent';
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Collect the developer's explicit approval for `cloudComputer: 'shell'`.
|
|
605
|
+
*
|
|
606
|
+
* The server never grants shell from authorship alone — holding an app's source
|
|
607
|
+
* (a scaffold, a pulled repo) is not consent to full-authority commands on the
|
|
608
|
+
* cloud computer — so `apps dev` asks once, records the answer in the linked
|
|
609
|
+
* state, and passes the grant with the ensure call. Runs before the parallel
|
|
610
|
+
* ensure fan-out so the prompt cannot interleave.
|
|
611
|
+
*/
|
|
612
|
+
export async function resolveCloudShellConsent({
|
|
613
|
+
appConfig,
|
|
614
|
+
projectDir,
|
|
615
|
+
grantCloudShell = false,
|
|
616
|
+
logger = console,
|
|
617
|
+
}) {
|
|
618
|
+
const capabilities = normalizeAppCapabilities(appConfig.capabilities);
|
|
619
|
+
if (capabilities.cloudComputer !== 'shell') {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
const approved = ['cloud_computer_read', 'cloud_computer_shell'];
|
|
623
|
+
const linkedState = readLinkedState(projectDir) || {};
|
|
624
|
+
const recordDecision = (decision) => {
|
|
625
|
+
writeLinkedState(projectDir, { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision });
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
if (grantCloudShell) {
|
|
629
|
+
recordDecision('granted');
|
|
630
|
+
return approved;
|
|
631
|
+
}
|
|
632
|
+
const recorded = linkedState[CLOUD_SHELL_CONSENT_KEY];
|
|
633
|
+
if (recorded === 'granted') {
|
|
634
|
+
return approved;
|
|
635
|
+
}
|
|
636
|
+
const declineHint =
|
|
637
|
+
`${appConfig.name}: cloud computer shell stays denied for this dev session. `
|
|
638
|
+
+ 'Re-run with --grant-cloud-shell to approve it.';
|
|
639
|
+
if (recorded === 'declined') {
|
|
640
|
+
logger.warn(declineHint);
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
644
|
+
logger.warn(declineHint);
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
const { createInterface } = await import('node:readline/promises');
|
|
648
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
649
|
+
try {
|
|
650
|
+
const answer = (await prompt.question(
|
|
651
|
+
`${appConfig.name} declares cloudComputer: 'shell' - its views will run commands and `
|
|
652
|
+
+ 'read or write files on your cloud computer with the same authority as your own '
|
|
653
|
+
+ 'agent. Allow for this dev app? [y/N] ',
|
|
654
|
+
)).trim().toLowerCase();
|
|
655
|
+
const granted = answer === 'y' || answer === 'yes';
|
|
656
|
+
recordDecision(granted ? 'granted' : 'declined');
|
|
657
|
+
if (!granted) {
|
|
658
|
+
logger.warn(declineHint);
|
|
659
|
+
}
|
|
660
|
+
return granted ? approved : null;
|
|
661
|
+
} finally {
|
|
662
|
+
prompt.close();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
541
666
|
export async function ensureDevInstall({
|
|
542
667
|
ctx,
|
|
543
668
|
appConfig,
|
|
544
669
|
projectDir,
|
|
545
670
|
idempotencyKey,
|
|
671
|
+
useInstalledDatabases = false,
|
|
672
|
+
approvedCapabilities = null,
|
|
546
673
|
runTool = runToolCommand,
|
|
547
674
|
}) {
|
|
548
675
|
if (!appConfig.name) {
|
|
@@ -581,7 +708,14 @@ export async function ensureDevInstall({
|
|
|
581
708
|
linkedApp = null;
|
|
582
709
|
}
|
|
583
710
|
}
|
|
584
|
-
const ensureArguments = buildEnsureDevInstallArguments({
|
|
711
|
+
const ensureArguments = buildEnsureDevInstallArguments({
|
|
712
|
+
appConfig,
|
|
713
|
+
manifest,
|
|
714
|
+
linkedState,
|
|
715
|
+
skills,
|
|
716
|
+
useInstalledDatabases,
|
|
717
|
+
approvedCapabilities,
|
|
718
|
+
});
|
|
585
719
|
const ensureResult = await runTool({
|
|
586
720
|
runtime: ctx.runtime,
|
|
587
721
|
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
@@ -613,6 +747,7 @@ export async function ensureDevInstall({
|
|
|
613
747
|
targetAppId: linkedState?.app_id || null,
|
|
614
748
|
targetAppSlug: linkedApp?.slug || null,
|
|
615
749
|
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
750
|
+
liveData: ensureResult.payload.live_data || null,
|
|
616
751
|
};
|
|
617
752
|
}
|
|
618
753
|
|
|
@@ -631,6 +766,12 @@ function databaseMaterializationWarnings(apps) {
|
|
|
631
766
|
return warnings;
|
|
632
767
|
}
|
|
633
768
|
|
|
769
|
+
function liveDataWarnings(apps) {
|
|
770
|
+
return apps
|
|
771
|
+
.filter((app) => app.liveData?.warning)
|
|
772
|
+
.map((app) => `${app.name}: ${app.liveData.warning}`);
|
|
773
|
+
}
|
|
774
|
+
|
|
634
775
|
async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
635
776
|
const result = await runTool({
|
|
636
777
|
runtime,
|
|
@@ -807,6 +948,7 @@ async function appsDevHandler(ctx) {
|
|
|
807
948
|
const desktopAppName = resolveDevelopmentDesktopAppName(ctx.runtime);
|
|
808
949
|
const desktopBundleId = resolveDevelopmentDesktopBundleId(ctx.runtime);
|
|
809
950
|
const sessionId = randomUUID();
|
|
951
|
+
const useInstalledDatabases = Boolean(ctx.options?.liveData);
|
|
810
952
|
|
|
811
953
|
const candidates = [];
|
|
812
954
|
for (const projectDir of appDirs) {
|
|
@@ -818,7 +960,14 @@ async function appsDevHandler(ctx) {
|
|
|
818
960
|
if (!devSlug) {
|
|
819
961
|
throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
|
|
820
962
|
}
|
|
821
|
-
|
|
963
|
+
// Sequential on purpose: the shell-consent prompt must never interleave
|
|
964
|
+
// with another app's, and the ensure fan-out below runs in parallel.
|
|
965
|
+
const approvedCapabilities = await resolveCloudShellConsent({
|
|
966
|
+
appConfig,
|
|
967
|
+
projectDir,
|
|
968
|
+
grantCloudShell: Boolean(ctx.options?.grantCloudShell),
|
|
969
|
+
});
|
|
970
|
+
candidates.push({ appConfig, devSlug, projectDir, approvedCapabilities });
|
|
822
971
|
}
|
|
823
972
|
|
|
824
973
|
const seenSlugs = new Map();
|
|
@@ -833,7 +982,7 @@ async function appsDevHandler(ctx) {
|
|
|
833
982
|
|
|
834
983
|
const registrationStartedAt = process.hrtime.bigint();
|
|
835
984
|
const registered = await Promise.all(
|
|
836
|
-
candidates.map(async ({ appConfig, devSlug, projectDir }) => {
|
|
985
|
+
candidates.map(async ({ appConfig, devSlug, projectDir, approvedCapabilities }) => {
|
|
837
986
|
const appStartedAt = process.hrtime.bigint();
|
|
838
987
|
try {
|
|
839
988
|
return await ensureDevInstall({
|
|
@@ -841,6 +990,8 @@ async function appsDevHandler(ctx) {
|
|
|
841
990
|
appConfig,
|
|
842
991
|
projectDir,
|
|
843
992
|
idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
|
|
993
|
+
useInstalledDatabases,
|
|
994
|
+
approvedCapabilities,
|
|
844
995
|
});
|
|
845
996
|
} finally {
|
|
846
997
|
logAppsTiming('ensure-dev-install', {
|
|
@@ -881,7 +1032,7 @@ async function appsDevHandler(ctx) {
|
|
|
881
1032
|
desktopScheme,
|
|
882
1033
|
sessionId,
|
|
883
1034
|
);
|
|
884
|
-
const warnings = databaseMaterializationWarnings(apps);
|
|
1035
|
+
const warnings = [...databaseMaterializationWarnings(apps), ...liveDataWarnings(apps)];
|
|
885
1036
|
|
|
886
1037
|
const devServer = await startAppDevServer({
|
|
887
1038
|
apps: apps.map((app) => ({
|
|
@@ -958,12 +1109,16 @@ async function appsDevHandler(ctx) {
|
|
|
958
1109
|
created: app.created,
|
|
959
1110
|
linked_app_id: app.linkedAppId,
|
|
960
1111
|
database_materialization: app.databaseMaterialization,
|
|
1112
|
+
live_data: app.liveData,
|
|
961
1113
|
})),
|
|
962
1114
|
},
|
|
963
1115
|
warnings,
|
|
964
1116
|
humanSummary: [
|
|
965
1117
|
`Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
|
|
966
1118
|
`Target desktop: ${desktopAppName}`,
|
|
1119
|
+
...(useInstalledDatabases
|
|
1120
|
+
? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
|
|
1121
|
+
: []),
|
|
967
1122
|
'',
|
|
968
1123
|
`Open in desktop: ${developmentTabUrl}`,
|
|
969
1124
|
'',
|
|
@@ -1120,10 +1275,16 @@ async function appsVerifyHandler(ctx) {
|
|
|
1120
1275
|
const manifest = readManifest(projectDir);
|
|
1121
1276
|
const appConfig = await loadAppConfig(projectDir);
|
|
1122
1277
|
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
1123
|
-
|
|
1278
|
+
// Store readiness is a publish concern, not a render concern. Verify reports
|
|
1279
|
+
// it so the gaps stay visible while the app is still being built; only
|
|
1280
|
+
// --listing (and `apps publish`) turn it back into a hard gate.
|
|
1281
|
+
if (listing.errors.length && ctx.options.listing === true) {
|
|
1124
1282
|
throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
|
|
1125
1283
|
}
|
|
1126
|
-
const listingWarnings =
|
|
1284
|
+
const listingWarnings = [
|
|
1285
|
+
...[...listing.errors, ...listing.warnings].map((message) => `Store readiness: ${message}`),
|
|
1286
|
+
...findUnknownScreenshotScenarios(projectDir, resolveListingScreenshots(projectDir, appConfig)),
|
|
1287
|
+
];
|
|
1127
1288
|
const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
|
|
1128
1289
|
const port = parsePort(ctx.options.port) || await getAvailablePort();
|
|
1129
1290
|
const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
|
|
@@ -1195,6 +1356,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1195
1356
|
result,
|
|
1196
1357
|
route,
|
|
1197
1358
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1359
|
+
mode,
|
|
1198
1360
|
);
|
|
1199
1361
|
return {
|
|
1200
1362
|
...result,
|
|
@@ -1219,6 +1381,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1219
1381
|
result,
|
|
1220
1382
|
route,
|
|
1221
1383
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1384
|
+
mode,
|
|
1222
1385
|
);
|
|
1223
1386
|
results.push({
|
|
1224
1387
|
route: route.slug,
|
|
@@ -1259,6 +1422,11 @@ async function appsVerifyHandler(ctx) {
|
|
|
1259
1422
|
},
|
|
1260
1423
|
summary,
|
|
1261
1424
|
results,
|
|
1425
|
+
listing: {
|
|
1426
|
+
ready: listing.ready,
|
|
1427
|
+
gated: ctx.options.listing === true,
|
|
1428
|
+
problems: listing.errors,
|
|
1429
|
+
},
|
|
1262
1430
|
};
|
|
1263
1431
|
|
|
1264
1432
|
ctx.output.emitSuccess({
|
|
@@ -1360,6 +1528,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
1360
1528
|
const configuredScreenshots = resolveListingScreenshots(projectDir, appConfig)
|
|
1361
1529
|
.filter((screenshot) => screenshot.route)
|
|
1362
1530
|
.filter((screenshot) => !selectedRouteSlugs || selectedRouteSlugs.includes(screenshot.route));
|
|
1531
|
+
const scenarioWarnings = findUnknownScreenshotScenarios(projectDir, configuredScreenshots);
|
|
1363
1532
|
const routeBySlug = new Map(routes.map((route) => [route.slug, route]));
|
|
1364
1533
|
const captures = configuredScreenshots.length > 0
|
|
1365
1534
|
? configuredScreenshots.map((screenshot) => {
|
|
@@ -1471,7 +1640,7 @@ async function appsScreenshotHandler(ctx) {
|
|
|
1471
1640
|
|
|
1472
1641
|
const captured = results.filter((r) => r.ok);
|
|
1473
1642
|
const failed = results.filter((r) => !r.ok);
|
|
1474
|
-
const warnings = [];
|
|
1643
|
+
const warnings = [...scenarioWarnings];
|
|
1475
1644
|
|
|
1476
1645
|
// Drop stale screenshots only after a full refresh. A selected-route
|
|
1477
1646
|
// capture intentionally leaves other listing screenshots untouched.
|
|
@@ -1642,7 +1811,14 @@ async function appsDeployHandler(ctx) {
|
|
|
1642
1811
|
let result;
|
|
1643
1812
|
try {
|
|
1644
1813
|
result = await runToolCommand({
|
|
1645
|
-
|
|
1814
|
+
// App deploys upload both the built artifact and the editable source
|
|
1815
|
+
// snapshot. The ordinary 30s CLI timeout is too short for larger apps,
|
|
1816
|
+
// and timing out a mutation is ambiguous: the backend may commit after
|
|
1817
|
+
// the client disconnects. Give this operation its real completion window.
|
|
1818
|
+
runtime: {
|
|
1819
|
+
...ctx.runtime,
|
|
1820
|
+
timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
|
|
1821
|
+
},
|
|
1646
1822
|
toolName: SAVE_APP_FILES_TOOL,
|
|
1647
1823
|
arguments_: {
|
|
1648
1824
|
app_id: appId,
|
|
@@ -1660,51 +1836,47 @@ async function appsDeployHandler(ctx) {
|
|
|
1660
1836
|
throw toolConflictToError(error.details, 'Deploy conflict');
|
|
1661
1837
|
}
|
|
1662
1838
|
|
|
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
|
-
}
|
|
1839
|
+
// A timed-out mutation may already have committed on the backend. A direct
|
|
1840
|
+
// upload here would create a second revision, so fail closed and let the
|
|
1841
|
+
// caller inspect/pull the app before deciding whether to retry.
|
|
1842
|
+
if (error.code === 'network_timeout') {
|
|
1843
|
+
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.`;
|
|
1844
|
+
throw error;
|
|
1845
|
+
}
|
|
1677
1846
|
|
|
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
|
-
}
|
|
1847
|
+
// A network failure is ambiguous for a mutation. It can happen after the
|
|
1848
|
+
// backend committed but before undici finished reading the response (for
|
|
1849
|
+
// example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
|
|
1850
|
+
// generic network_error or its message: an automatic direct upload could
|
|
1851
|
+
// create a second revision. Operators who have proved the backend is down
|
|
1852
|
+
// can still choose the explicit --direct mode.
|
|
1853
|
+
if (error.code === 'network_error') {
|
|
1854
|
+
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.`;
|
|
1855
|
+
throw error;
|
|
1694
1856
|
}
|
|
1695
1857
|
|
|
1696
1858
|
throw error;
|
|
1697
1859
|
}
|
|
1698
1860
|
|
|
1699
|
-
|
|
1861
|
+
const deployedVersion = Number(result?.payload?.version);
|
|
1862
|
+
if (!Number.isFinite(deployedVersion) || deployedVersion <= 0) {
|
|
1863
|
+
throw new CliError({
|
|
1864
|
+
code: 'network_error',
|
|
1865
|
+
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.',
|
|
1866
|
+
exitCode: EXIT_CODES.network,
|
|
1867
|
+
retryable: true,
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion);
|
|
1700
1872
|
return ctx.output.emitSuccess({
|
|
1701
1873
|
command: ctx.spec.command_path.join(' '),
|
|
1702
1874
|
data: {
|
|
1703
1875
|
app_id: appId,
|
|
1704
|
-
version:
|
|
1876
|
+
version: deployedVersion,
|
|
1705
1877
|
idempotency_key: idempotencyKey,
|
|
1706
1878
|
},
|
|
1707
|
-
humanSummary: `Deployed to app ${appId} (version ${
|
|
1879
|
+
humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
|
|
1708
1880
|
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
1709
1881
|
});
|
|
1710
1882
|
}
|
|
@@ -1988,12 +2160,25 @@ export const appsCommandSpecs = [
|
|
|
1988
2160
|
options: [
|
|
1989
2161
|
{ flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
|
|
1990
2162
|
{ flags: '--no-open', description: 'Do not auto-open the desktop Portal local development app.' },
|
|
2163
|
+
{
|
|
2164
|
+
flags: '--live-data',
|
|
2165
|
+
description:
|
|
2166
|
+
"Read and write the installed app's real databases instead of empty dev copies. "
|
|
2167
|
+
+ 'Applies to this session only; warns and falls back when the app is not installed yet.',
|
|
2168
|
+
},
|
|
2169
|
+
{
|
|
2170
|
+
flags: '--grant-cloud-shell',
|
|
2171
|
+
description:
|
|
2172
|
+
"Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
|
|
2173
|
+
+ 'The grant persists for this dev app; authorship alone never grants it.',
|
|
2174
|
+
},
|
|
1991
2175
|
],
|
|
1992
2176
|
},
|
|
1993
2177
|
examples: [
|
|
1994
2178
|
'notis apps dev',
|
|
1995
2179
|
'notis apps dev ./my-app',
|
|
1996
2180
|
'notis apps dev ./workspace --port 5200',
|
|
2181
|
+
'notis apps dev --live-data # iterate on a view over the installed app\'s real rows',
|
|
1997
2182
|
],
|
|
1998
2183
|
mutates: true,
|
|
1999
2184
|
idempotent: true,
|
|
@@ -2020,10 +2205,11 @@ export const appsCommandSpecs = [
|
|
|
2020
2205
|
},
|
|
2021
2206
|
{
|
|
2022
2207
|
command_path: ['apps', 'verify'],
|
|
2023
|
-
summary: 'Validate every route and
|
|
2208
|
+
summary: 'Validate that every route renders and reports Store listing readiness.',
|
|
2024
2209
|
when_to_use:
|
|
2025
|
-
'
|
|
2026
|
-
'missing runtime calls
|
|
2210
|
+
'Any time after notis apps build, and before deploy. Catches render-time crashes and ' +
|
|
2211
|
+
'missing runtime calls. Incomplete listing media is reported as a warning; pass --listing ' +
|
|
2212
|
+
'to fail on it instead.',
|
|
2027
2213
|
args_schema: {
|
|
2028
2214
|
arguments: [
|
|
2029
2215
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
@@ -2032,7 +2218,8 @@ export const appsCommandSpecs = [
|
|
|
2032
2218
|
{ flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
|
|
2033
2219
|
{ flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
|
|
2034
2220
|
{ 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.' },
|
|
2221
|
+
{ 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.' },
|
|
2222
|
+
{ flags: '--listing', description: 'Fail instead of warn when the Store listing (tagline, categories, screenshots, changelog) is incomplete.' },
|
|
2036
2223
|
{ flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
|
|
2037
2224
|
{ flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
|
|
2038
2225
|
],
|
|
@@ -2041,6 +2228,7 @@ export const appsCommandSpecs = [
|
|
|
2041
2228
|
'notis apps verify',
|
|
2042
2229
|
'notis apps verify --routes notes',
|
|
2043
2230
|
'notis apps verify --mode live',
|
|
2231
|
+
'notis apps verify --listing # gate on Store listing readiness before publish',
|
|
2044
2232
|
'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
|
|
2045
2233
|
],
|
|
2046
2234
|
mutates: false,
|