@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.1792022492092.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +548 -98
- package/config/portal-route-roots.json +47 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +32901 -5934
- package/dist/base-skills/notis-apps/SKILL.md +76 -51
- package/dist/base-skills/notis-apps/references/architecture.md +116 -164
- package/dist/base-skills/notis-apps/references/design.md +26 -107
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +52 -74
- package/dist/base-skills/notis-apps/references/release.md +105 -96
- package/dist/base-skills/notis-apps/references/sdk.md +115 -60
- package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
- package/dist/base-skills/notis-apps/references/views.md +145 -0
- package/dist/base-skills/notis-cli/SKILL.md +29 -12
- package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +6 -5
- package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
- package/dist/base-skills/notis-query/SKILL.md +34 -60
- package/dist/base-skills/notis-query/references/database-discovery.md +60 -59
- package/dist/base-skills/notis-query/references/documents.md +53 -39
- package/dist/base-skills/notis-query/references/query.md +57 -524
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
- package/dist/sdk/src/components/DocumentPage.tsx +144 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/config.ts +4 -26
- package/dist/sdk/src/documents.ts +2 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useBackend.ts +3 -2
- package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useCollectionInteractions.ts +4 -2
- package/dist/sdk/src/hooks/useHandover.ts +7 -7
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +3 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
- package/dist/sdk/src/hooks/useQuery.ts +7 -4
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +36 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +42 -5
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +9 -4
- package/dist/sdk/src/queryCache.ts +5 -3
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +163 -13
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +96 -72
- package/dist/sdk/src/vite.ts +11 -1
- package/dist/skill-sync/index.js +571 -80
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +858 -233
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1028 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +175 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +16 -5
- package/skills/notis-apps/cli.md +323 -163
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
- package/skills/notis-onboarding/BRIEF.md +7 -3
- package/src/command-specs/apps.js +2 -1
- package/src/command-specs/auth.js +2 -2
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +331 -79
- package/src/command-specs/helpers.js +2 -0
- package/src/command-specs/index.js +11 -5
- package/src/command-specs/profile.js +42 -1
- package/src/command-specs/skills.js +242 -1
- package/src/command-specs/spaces.js +645 -0
- package/src/command-specs/tools.js +0 -6
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +92 -29
- package/src/runtime/app-boundary-validator.js +7 -4
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +321 -253
- package/src/runtime/computer-identity.d.ts +30 -0
- package/src/runtime/computer-identity.js +151 -0
- package/src/runtime/config-lock.js +135 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +5 -21
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +18 -1
- package/src/runtime/profiles.js +5 -107
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/skill-sync/cloud-client.ts +26 -4
- package/src/runtime/skill-sync/index.ts +230 -35
- package/src/runtime/skill-sync/local-scanner.ts +188 -29
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +70 -0
- package/src/runtime/skill-sync/sync-plan.ts +16 -9
- package/src/runtime/skill-sync/types.ts +32 -6
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/store-screenshot.js +10 -7
- package/src/runtime/sync-skills.d.ts +3 -1
- package/src/runtime/sync-skills.js +10 -2
- package/src/runtime/transport.js +8 -6
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker.js +14 -2
- package/src/space-harness-host.ts +103 -0
- package/template/.harness/index.html.tmpl +7 -10
- package/template/app/globals.css +1 -0
- package/src/command-specs/reports.js +0 -97
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Frozen offline fixtures rendered by the same read-only library as CLI/service. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { appFilesDigest } from './app-platform.js';
|
|
6
|
+
import { createSpaceFixtureBroker } from './space-fixture-broker.js';
|
|
7
|
+
|
|
8
|
+
// Kept for the standalone legacy worker's lifecycle tests while that diagnostic
|
|
9
|
+
// entrypoint is retired. The primary verifier below never starts that worker.
|
|
10
|
+
export function spaceVerificationEnvironment(environment, { configPath }) {
|
|
11
|
+
const names = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'SystemRoot', 'WINDIR', 'DISPLAY', 'XDG_RUNTIME_DIR'];
|
|
12
|
+
return { ...Object.fromEntries(names.filter(name => typeof environment[name] === 'string').map(name => [name, environment[name]])),
|
|
13
|
+
AGENT_BROWSER_CONFIG: configPath, AGENT_BROWSER_NO_WEBMCP: '1' };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function loadSpaceVerificationRenderer() {
|
|
17
|
+
const candidates = [new URL('../../dist/view-renderer/', import.meta.url), new URL('../../../view-renderer/', import.meta.url)];
|
|
18
|
+
for (const root of candidates) {
|
|
19
|
+
const entry = new URL('src/index.js', root);
|
|
20
|
+
if (!existsSync(fileURLToPath(entry))) continue;
|
|
21
|
+
const digest = createHash('sha256');
|
|
22
|
+
for (const file of ['src/renderer.js', 'src/dom.js', 'src/pool.js', 'dist/host.js', 'dist/host.css', 'dist/theme.css']) {
|
|
23
|
+
digest.update(file); digest.update(readFileSync(new URL(file, root)));
|
|
24
|
+
}
|
|
25
|
+
return { ...(await import(entry.href)), rendererDigest: digest.digest('hex') };
|
|
26
|
+
}
|
|
27
|
+
throw new Error('The shared view renderer is missing. Rebuild or reinstall this CLI package.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// One browser per verification, two widths concurrently; no worker creates an
|
|
31
|
+
// unbounded browser fanout. Up to two invocations may run simultaneously.
|
|
32
|
+
let active = 0;
|
|
33
|
+
const waiting = [];
|
|
34
|
+
async function acquire(signal) {
|
|
35
|
+
if (signal?.aborted) throw new Error('Verification cancelled.');
|
|
36
|
+
if (active < 2) { active++; return; }
|
|
37
|
+
if (waiting.length >= 8) throw new Error('The local verification queue is full.');
|
|
38
|
+
await new Promise((accept, reject) => {
|
|
39
|
+
const entry = { accept: () => { signal?.removeEventListener('abort', abort); accept(); } };
|
|
40
|
+
const abort = () => { const index = waiting.indexOf(entry); if(index >= 0) waiting.splice(index, 1); reject(new Error('Verification cancelled.')); };
|
|
41
|
+
waiting.push(entry); signal?.addEventListener('abort', abort, { once: true });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function releaseSlot() { const next = waiting.shift(); if (next) next.accept(); else active--; }
|
|
45
|
+
|
|
46
|
+
/** Initial deployment checks the exact captured bytes at both product widths. */
|
|
47
|
+
export async function verifySpaceRelease(release, { capabilities, timeoutMs = 45_000, signal, loadRenderer = loadSpaceVerificationRenderer,
|
|
48
|
+
authorizationMode = 'authorize_missing', unavailableActions = [] } = {}) {
|
|
49
|
+
const diagnostic = { schema: 'notis-space-render/v1', local_key: release.manifest.local_key,
|
|
50
|
+
source_digest: appFilesDigest(release.sourceFiles), artifact_digest: appFilesDigest(release.files),
|
|
51
|
+
mode: 'offline_fixtures', checked_at: new Date().toISOString(),
|
|
52
|
+
...(authorizationMode === 'reuse_only' ? { authorization_mode: authorizationMode,
|
|
53
|
+
unavailable_actions: structuredClone(unavailableActions) } : {}) };
|
|
54
|
+
if (signal?.aborted) return { ...diagnostic, status: 'failed', errors: [{ phase: 'browser', message: 'Verification cancelled.' }] };
|
|
55
|
+
if (release.manifest.kind === 'container') return { ...diagnostic, status: 'not_applicable_container' };
|
|
56
|
+
let pool, held = false, rendererDigest;
|
|
57
|
+
const brokers = [390, 1440].map(width => ({ width, broker: null }));
|
|
58
|
+
try {
|
|
59
|
+
// Validate fixture authority before opening a browser or acquiring a slot.
|
|
60
|
+
for (const item of brokers) item.broker = createSpaceFixtureBroker({ release, capabilities, width: item.width,
|
|
61
|
+
authorizationMode, unavailableActions });
|
|
62
|
+
await acquire(signal); held = true;
|
|
63
|
+
const library = await loadRenderer(); rendererDigest = library.rendererDigest;
|
|
64
|
+
pool = library.createBrowserPool({ concurrency: 2, maxQueue: 2 });
|
|
65
|
+
const observations = await Promise.allSettled(brokers.map(async ({ width, broker }) => {
|
|
66
|
+
const result = await library.renderView({ broker, pool, outputs: ['markdown', 'screenshot'], width, timeoutMs, signal });
|
|
67
|
+
const blocked = result.blocked_calls || [];
|
|
68
|
+
return { width, mounted: true, status: blocked.length ? 'failed' : 'passed',
|
|
69
|
+
markdown_sha256: createHash('sha256').update(result.markdown).digest('hex'),
|
|
70
|
+
screenshot_sha256: createHash('sha256').update(Buffer.from(result.screenshot.data_base64, 'base64')).digest('hex'),
|
|
71
|
+
screenshot_width: result.screenshot.width, screenshot_height: result.screenshot.height,
|
|
72
|
+
rendered_at: result.rendered_at, blocked_calls: blocked };
|
|
73
|
+
}));
|
|
74
|
+
const renders = observations.map((value, index) => value.status === 'fulfilled' ? value.value : {
|
|
75
|
+
width: brokers[index].width, mounted: false, status: 'failed', error: value.reason?.code || 'render_failed',
|
|
76
|
+
});
|
|
77
|
+
const runtimeCalls = brokers.flatMap(({ width, broker }) => broker.calls.map(call => ({ width, ...call })));
|
|
78
|
+
const passed = renders.every(value => value.status === 'passed') && runtimeCalls.every(call => call.ok);
|
|
79
|
+
return { ...diagnostic, status: passed ? 'passed' : 'failed', mounted: renders.every(value => value.mounted),
|
|
80
|
+
fixture_digest: brokers[0].broker.fixtureDigest, renderer_digest: rendererDigest,
|
|
81
|
+
render_count: renders.length, browser_count: 1, renders, runtimeCalls,
|
|
82
|
+
errors: passed ? [] : [{ phase: 'renderer', message: 'At least one width or read-only fixture failed verification.' }] };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { ...diagnostic, status: 'failed', ...(rendererDigest ? { renderer_digest: rendererDigest } : {}),
|
|
85
|
+
errors: [{ phase: 'harness', message: error.message }] };
|
|
86
|
+
} finally {
|
|
87
|
+
try { await pool?.close(); } finally { if (held) releaseSlot(); }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Runs in a credential-free child process, never in the authenticated CLI. */
|
|
2
|
+
import { closeAgentBrowserSessionResult, isAgentBrowserAvailable, runHarnessRoute } from './agent-browser.js';
|
|
3
|
+
|
|
4
|
+
let sessionName;
|
|
5
|
+
let closing;
|
|
6
|
+
let started = false;
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
let exitCode = 0;
|
|
9
|
+
const cleanup = () => closing ||= sessionName ? closeAgentBrowserSessionResult(sessionName) : Promise.resolve({ ok: true });
|
|
10
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
11
|
+
exitCode = signal === 'SIGINT' ? 130 : 143;
|
|
12
|
+
controller.abort();
|
|
13
|
+
if (!started) process.exit(exitCode);
|
|
14
|
+
});
|
|
15
|
+
process.on('disconnect', () => { exitCode = 1; controller.abort(); if (!started) process.exit(1); });
|
|
16
|
+
|
|
17
|
+
process.once('message', async input => {
|
|
18
|
+
started = true;
|
|
19
|
+
let result;
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(input.url);
|
|
22
|
+
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port
|
|
23
|
+
|| !/^notis-space-[a-f0-9-]+$/.test(input.sessionName)) throw new Error('Invalid offline verification session.');
|
|
24
|
+
sessionName = input.sessionName;
|
|
25
|
+
if (!isAgentBrowserAvailable()) throw new Error('Install agent-browser and its Chromium browser to verify a presentation.');
|
|
26
|
+
const observed = await runHarnessRoute({ url: url.href, sessionName, timeoutMs: input.timeoutMs,
|
|
27
|
+
designViewports: [], waitForRuntime: true, signal: controller.signal });
|
|
28
|
+
// Never retain raw eval/stdout or action inputs/results in a diagnostic receipt.
|
|
29
|
+
result = {
|
|
30
|
+
mounted: observed.mounted,
|
|
31
|
+
renderStarted: observed.renderStarted,
|
|
32
|
+
timed_out: Boolean(observed.timed_out),
|
|
33
|
+
errors: (observed.errors || []).map(error => ({ phase: String(error.phase).slice(0, 100), message: String(error.message).slice(0, 2000) })),
|
|
34
|
+
runtimeCalls: (observed.runtimeCalls || []).map(call => ({ actionId: call.actionId, ok: call.ok,
|
|
35
|
+
...(call.error ? { error: String(call.error).slice(0, 2000) } : {}) })),
|
|
36
|
+
...(observed.tool_error ? { tool_error: { phase: observed.tool_error.phase,
|
|
37
|
+
message: String(observed.tool_error.stderr || observed.tool_error.message
|
|
38
|
+
|| 'The isolated verification browser could not complete this operation.').slice(0, 2000) } } : {}),
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
result = { mounted: false, errors: [{ phase: 'browser', message: error.message }], runtimeCalls: [] };
|
|
42
|
+
} finally {
|
|
43
|
+
if (process.connected) {
|
|
44
|
+
try { process.send({ phase: 'cleanup' }, () => {}); } catch { /* Cleanup still owns the browser after IPC loss. */ }
|
|
45
|
+
}
|
|
46
|
+
const closed = await cleanup();
|
|
47
|
+
result = { ...result, cleanup: closed, ...(!closed.ok ? { cleanup_failed: true } : {}) };
|
|
48
|
+
}
|
|
49
|
+
if (process.connected) process.send(result, () => process.exit(exitCode));
|
|
50
|
+
else process.exit(exitCode);
|
|
51
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a view declares (V4): path, typed params, shows, memory, chrome and a Markdown override.
|
|
3
|
+
* Mirrors server/lib/space_view_manifest.py, which re-checks every release; property-level checks
|
|
4
|
+
* need the linked schemas and run on the server during `spaces verify` and deploy.
|
|
5
|
+
* Every presentation requires path, description and memory, including sources that omit
|
|
6
|
+
* specVersion. Containers remain non-executable; explicit specVersion 2 is unchanged.
|
|
7
|
+
*/
|
|
8
|
+
import { usageError } from './errors.js';
|
|
9
|
+
import portalRouteRoots from '../../config/portal-route-roots.json' with { type: 'json' };
|
|
10
|
+
const RESERVED_PATHS = new Set(portalRouteRoots);
|
|
11
|
+
|
|
12
|
+
export const VIEW_SPEC_VERSION = 2;
|
|
13
|
+
export const VIEW_DEFINITION_FIELDS = Object.freeze(['specVersion', 'path', 'params', 'shows', 'memory', 'chrome', 'markdown']);
|
|
14
|
+
export const VIEW_PARAM_TYPES = Object.freeze(['record', 'enum', 'text', 'number', 'date', 'boolean']);
|
|
15
|
+
const RESERVED_PARAMS = new Set(['record', 'assistantThread']);
|
|
16
|
+
const PAGE_ONLY = ['params', 'shows', 'memory', 'chrome', 'markdown'];
|
|
17
|
+
const NAME = /^[a-z][A-Za-z0-9_]{0,63}$/;
|
|
18
|
+
// Credentials never ride in links, so no view param may look like one (the Portal drops these names).
|
|
19
|
+
const CREDENTIAL_PARAM = /^(?:jwt|code|state|key)$|token|secret|password|credential|session|auth|api_?key/i;
|
|
20
|
+
const WORD = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
21
|
+
const ID_TAIL = /(?:^|-)(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
22
|
+
const PLACEHOLDER = /^\{([a-z][A-Za-z0-9_]{0,63})(?:\.([^{}.\s][^{}]{0,199}))?\}$/;
|
|
23
|
+
const RECORD_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
24
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
25
|
+
const SERVICE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
28
|
+
const FIELD_KEYS = ['property', 'property_id', 'column'];
|
|
29
|
+
const OPS = new Set(['equals', 'not_equals', 'in', 'contains', 'not_contains', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty',
|
|
30
|
+
'greater_than', 'less_than', 'greater_than_or_equal_to', 'less_than_or_equal_to', 'does_not_contain', 'before', 'after',
|
|
31
|
+
'on_or_before', 'on_or_after']);
|
|
32
|
+
const CRON_BOUNDS = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
|
|
33
|
+
const DEBOUNCE = [60, 86400];
|
|
34
|
+
|
|
35
|
+
const plain = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
36
|
+
const text = (value, limit) => typeof value === 'string' && value.trim().length >= 1 && value.trim().length <= limit && !CONTROL.test(value);
|
|
37
|
+
const isoDate = value => typeof value === 'string' && DATE.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
|
|
38
|
+
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
|
|
39
|
+
|
|
40
|
+
export function validateViewPath(value) {
|
|
41
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 100) {
|
|
42
|
+
throw usageError('Declare path as the words of this view\'s links, such as "notes" or "seo/keywords".');
|
|
43
|
+
}
|
|
44
|
+
const segments = value.split('/');
|
|
45
|
+
if (segments.length > 4 || segments.some(segment => !WORD.test(segment) || ID_TAIL.test(segment))) {
|
|
46
|
+
throw usageError('A path is up to four lowercase words (a-z, 0-9, dashes) separated by "/", without ids.');
|
|
47
|
+
}
|
|
48
|
+
if (RESERVED_PATHS.has(segments[0])) throw usageError(`The path "${segments[0]}" belongs to the Portal. Choose another first word.`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validCron(value) {
|
|
53
|
+
const fields = typeof value === 'string' ? value.split(' ') : [];
|
|
54
|
+
if (fields.length !== 5) return false;
|
|
55
|
+
return fields.every((field, index) => {
|
|
56
|
+
const [low, high] = CRON_BOUNDS[index];
|
|
57
|
+
const inRange = number => /^\d+$/.test(number) && Number(number) >= low && Number(number) <= high;
|
|
58
|
+
if (index === 0) return inRange(field);
|
|
59
|
+
return field.split(',').every(part => {
|
|
60
|
+
const [base, step, extra] = part.split('/');
|
|
61
|
+
if (extra !== undefined || (step !== undefined && !(/^\d+$/.test(step) && Number(step) >= 1 && Number(step) <= high))) return false;
|
|
62
|
+
if (base === '*') return true;
|
|
63
|
+
const [start, end, more] = base.split('-');
|
|
64
|
+
if (more !== undefined || !inRange(start) || (end !== undefined && (!inRange(end) || Number(start) > Number(end)))) return false;
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateMemory(memory) {
|
|
71
|
+
if (!plain(memory) || Object.keys(memory).sort().join(',') !== 'attachments,markdown,screenshot,snapshots') {
|
|
72
|
+
throw usageError('Declare memory as { markdown, attachments, screenshot, snapshots }; see the memory guide.');
|
|
73
|
+
}
|
|
74
|
+
if (['markdown', 'attachments', 'screenshot'].some(name => typeof memory[name] !== 'boolean')) {
|
|
75
|
+
throw usageError('memory.markdown, memory.attachments and memory.screenshot are true or false.');
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(memory.snapshots) || memory.snapshots.length > 3) {
|
|
78
|
+
throw usageError('memory.snapshots lists on_render, { scheduled } and { on_change }, each at most once ([] for none).');
|
|
79
|
+
}
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const strategy of memory.snapshots) {
|
|
82
|
+
let kind;
|
|
83
|
+
if (strategy === 'on_render') kind = 'on_render';
|
|
84
|
+
else if (plain(strategy) && Object.keys(strategy).join(',') === 'scheduled') {
|
|
85
|
+
kind = 'scheduled';
|
|
86
|
+
if (!validCron(strategy.scheduled)) throw usageError('A scheduled snapshot uses a five-field UTC cron with a fixed minute, such as "0 7 * * 1".');
|
|
87
|
+
} else if (plain(strategy) && Object.keys(strategy).join(',') === 'on_change') {
|
|
88
|
+
kind = 'on_change';
|
|
89
|
+
const debounce = strategy.on_change?.debounceSeconds;
|
|
90
|
+
if (!plain(strategy.on_change) || Object.keys(strategy.on_change).join(',') !== 'debounceSeconds' || !Number.isInteger(debounce)
|
|
91
|
+
|| debounce < DEBOUNCE[0] || debounce > DEBOUNCE[1]) {
|
|
92
|
+
throw usageError(`An on_change snapshot waits debounceSeconds (${DEBOUNCE[0]} to ${DEBOUNCE[1]}) after the last change.`);
|
|
93
|
+
}
|
|
94
|
+
} else throw usageError('memory.snapshots lists on_render, { scheduled: "<cron>" } or { on_change: { debounceSeconds } }.');
|
|
95
|
+
if (seen.has(kind)) throw usageError('Declare each memory snapshot strategy at most once.');
|
|
96
|
+
seen.add(kind);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One typed param value, as the SDK and the server accept it. */
|
|
101
|
+
export function viewParamValue(declaration, value) {
|
|
102
|
+
switch (declaration.type) {
|
|
103
|
+
case 'text': return typeof value === 'string' && value.length <= 500 && !CONTROL.test(value);
|
|
104
|
+
case 'enum': return typeof value === 'string' && (declaration.values || []).includes(value);
|
|
105
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value);
|
|
106
|
+
case 'boolean': return typeof value === 'boolean';
|
|
107
|
+
case 'date': return isoDate(value);
|
|
108
|
+
case 'record': return typeof value === 'string' && RECORD_KEY.test(value);
|
|
109
|
+
default: return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateParam(name, declaration, resources) {
|
|
114
|
+
if (!NAME.test(name) || RESERVED_PARAMS.has(name) || CREDENTIAL_PARAM.test(name)) throw usageError(`Param names are camelCase words; "${name}" is not available.`);
|
|
115
|
+
const allowed = new Set(['type', 'description', 'required', 'default', 'values', 'database', 'main', 'slugProperty']);
|
|
116
|
+
if (!plain(declaration) || Object.keys(declaration).some(field => !allowed.has(field)) || !VIEW_PARAM_TYPES.includes(declaration.type)) {
|
|
117
|
+
throw usageError(`Declare param "${name}" as { type, description, ... } with type ${VIEW_PARAM_TYPES.join(', ')}.`);
|
|
118
|
+
}
|
|
119
|
+
if (!text(declaration.description, 500)) throw usageError(`Describe param "${name}": what it selects, for agents and people.`);
|
|
120
|
+
if ('required' in declaration && typeof declaration.required !== 'boolean') throw usageError(`Param "${name}": required is true or false.`);
|
|
121
|
+
if (declaration.type === 'enum') {
|
|
122
|
+
const values = declaration.values;
|
|
123
|
+
if (!Array.isArray(values) || values.length < 1 || values.length > 100 || new Set(values).size !== values.length
|
|
124
|
+
|| values.some(value => !text(value, 100))) throw usageError(`Param "${name}": an enum lists its distinct values.`);
|
|
125
|
+
} else if ('values' in declaration) throw usageError(`Param "${name}": only enum params list values.`);
|
|
126
|
+
if (declaration.type === 'record') {
|
|
127
|
+
if (resources[declaration.database]?.kind !== 'database') {
|
|
128
|
+
throw usageError(`Param "${name}": a record param names a database this Space declares in resources.`);
|
|
129
|
+
}
|
|
130
|
+
if ('main' in declaration && typeof declaration.main !== 'boolean') throw usageError(`Param "${name}": main is true or false.`);
|
|
131
|
+
if ('slugProperty' in declaration && !text(declaration.slugProperty, 200)) throw usageError(`Param "${name}": slugProperty names a property of its database.`);
|
|
132
|
+
if ('default' in declaration) throw usageError(`Param "${name}": a record param has no default record.`);
|
|
133
|
+
} else if (['database', 'main', 'slugProperty'].some(field => field in declaration)) {
|
|
134
|
+
throw usageError(`Param "${name}": database, main and slugProperty belong to record params.`);
|
|
135
|
+
}
|
|
136
|
+
if ('default' in declaration) {
|
|
137
|
+
if (declaration.required === true) throw usageError(`Param "${name}": a required param has no default.`);
|
|
138
|
+
if (!viewParamValue(declaration, declaration.default)) throw usageError(`Param "${name}": its default must be a valid ${declaration.type} value.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkPlaceholder(value, params, collection, where) {
|
|
143
|
+
const match = typeof value === 'string' ? PLACEHOLDER.exec(value) : null;
|
|
144
|
+
if (!match) return;
|
|
145
|
+
const [, name, property] = match;
|
|
146
|
+
if (name === 'record') {
|
|
147
|
+
const records = Object.values(params).filter(declaration => declaration.type === 'record');
|
|
148
|
+
if (records.length === 1 || (!records.length && collection)) return;
|
|
149
|
+
throw usageError('Use {record} only in a view with exactly one record param (or a collection); otherwise name the param, such as {project}.');
|
|
150
|
+
}
|
|
151
|
+
if (!Object.hasOwn(params, name)) throw usageError(`${where}: "{${name}}" names no declared param.`);
|
|
152
|
+
if (property !== undefined && params[name].type !== 'record') throw usageError(`${where}: only record params have properties, such as {${name}}.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function walkFilter(node, visit, where, depth = 0, budget = { left: 200 }) {
|
|
156
|
+
budget.left -= 1;
|
|
157
|
+
if (depth > 12 || budget.left < 0) throw usageError(`${where}: the filter is too complex; use fewer conditions.`);
|
|
158
|
+
if (depth === 0 && plain(node) && !Object.keys(node).length) return;
|
|
159
|
+
if (!plain(node)) throw usageError(`${where}: a filter is an object.`);
|
|
160
|
+
for (const group of ['and', 'or']) {
|
|
161
|
+
if (!Object.hasOwn(node, group)) continue;
|
|
162
|
+
const children = node[group];
|
|
163
|
+
if (Object.keys(node).length !== 1 || !Array.isArray(children) || !children.length) throw usageError(`${where}: filter groups need at least one condition.`);
|
|
164
|
+
children.forEach(child => walkFilter(child, visit, where, depth + 1, budget));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const field = node.field;
|
|
168
|
+
if (Object.keys(node).some(key => !['field', 'op', 'value'].includes(key)) || !('field' in node) || !('op' in node)
|
|
169
|
+
|| !plain(field) || Object.keys(field).length !== 1 || !FIELD_KEYS.includes(Object.keys(field)[0])
|
|
170
|
+
|| typeof Object.values(field)[0] !== 'string' || !OPS.has(node.op)) {
|
|
171
|
+
throw usageError(`${where}: each condition is { field: { property }, op, value } in the native filter language.`);
|
|
172
|
+
}
|
|
173
|
+
visit(node);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateShows(name, declaration, params, resources, collection) {
|
|
177
|
+
const where = `shows.${name}`;
|
|
178
|
+
if (!NAME.test(name)) throw usageError(`Shows names are camelCase words; "${name}" is not available.`);
|
|
179
|
+
if (plain(declaration) && 'about' in declaration) {
|
|
180
|
+
const services = declaration.services ?? [];
|
|
181
|
+
if (Object.keys(declaration).some(field => !['about', 'services'].includes(field)) || !text(declaration.about, 500)
|
|
182
|
+
|| !Array.isArray(services) || services.length > 20 || services.some(service => typeof service !== 'string' || !SERVICE.test(service))) {
|
|
183
|
+
throw usageError(`${where}: describe what it shows as { about, services? }.`);
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!plain(declaration) || Object.keys(declaration).some(field => !['database', 'where', 'open'].includes(field)) || !('where' in declaration)) {
|
|
188
|
+
throw usageError(`${where}: declare { database, where, open? } for Notis data or { about, services? } otherwise.`);
|
|
189
|
+
}
|
|
190
|
+
if (resources[declaration.database]?.kind !== 'database') {
|
|
191
|
+
throw usageError(`${where}: "${declaration.database}" is not a database this Space declares and links.`);
|
|
192
|
+
}
|
|
193
|
+
if (declaration.open !== undefined && (params[declaration.open]?.type !== 'record' || params[declaration.open].database !== declaration.database)) {
|
|
194
|
+
throw usageError(`${where}: open names a record param over the same database.`);
|
|
195
|
+
}
|
|
196
|
+
walkFilter(declaration.where, condition => {
|
|
197
|
+
if (!('value' in condition)) return;
|
|
198
|
+
const values = condition.op === 'in' && Array.isArray(condition.value) ? condition.value : [condition.value];
|
|
199
|
+
values.forEach(value => checkPlaceholder(value, params, collection, where));
|
|
200
|
+
}, where);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Validates V4 declarations independently of the optional version marker, then returns
|
|
205
|
+
* their manifest form without inventing a version or page fields for containers.
|
|
206
|
+
*/
|
|
207
|
+
export function viewManifestFields(definition, { entry, resources }) {
|
|
208
|
+
const container = !entry;
|
|
209
|
+
const version = definition.specVersion;
|
|
210
|
+
if (version !== undefined && version !== VIEW_SPEC_VERSION) throw usageError(`specVersion is ${VIEW_SPEC_VERSION} for sources that declare the view manifest.`);
|
|
211
|
+
if (container && PAGE_ONLY.some(field => definition[field] !== undefined)) {
|
|
212
|
+
throw usageError('A container has no page: params, shows, memory, chrome and markdown belong to its views.');
|
|
213
|
+
}
|
|
214
|
+
if (!container || version === VIEW_SPEC_VERSION) {
|
|
215
|
+
const missing = ['path', 'description'].filter(field => !definition[field]);
|
|
216
|
+
if (!container && definition.memory === undefined) missing.push('memory');
|
|
217
|
+
if (missing.length) throw usageError(`This view needs ${missing.join(', ')} (specVersion 2).`);
|
|
218
|
+
if (!text(definition.description, 300)) throw usageError('Describe this view in one line (up to 300 characters).');
|
|
219
|
+
}
|
|
220
|
+
if (definition.path !== undefined) validateViewPath(definition.path);
|
|
221
|
+
const params = definition.params ?? {};
|
|
222
|
+
if (definition.params !== undefined) {
|
|
223
|
+
if (!plain(params) || Object.keys(params).length > 50) throw usageError('Declare params as { name: { type, description, ... } }.');
|
|
224
|
+
const mains = new Map();
|
|
225
|
+
for (const [name, declaration] of Object.entries(params)) {
|
|
226
|
+
validateParam(name, declaration, resources);
|
|
227
|
+
if (declaration.type !== 'record' || declaration.main !== true) continue;
|
|
228
|
+
if (mains.has(declaration.database)) {
|
|
229
|
+
throw usageError(`Params "${mains.get(declaration.database)}" and "${name}" both claim main for database "${declaration.database}"; a database has one main view.`);
|
|
230
|
+
}
|
|
231
|
+
mains.set(declaration.database, name);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (definition.shows !== undefined) {
|
|
235
|
+
if (!plain(definition.shows) || Object.keys(definition.shows).length > 50) {
|
|
236
|
+
throw usageError('Declare shows as { name: { database, where, open? } } or { name: { about, services? } }.');
|
|
237
|
+
}
|
|
238
|
+
for (const [name, declaration] of Object.entries(definition.shows)) validateShows(name, declaration, params, resources, definition.collection);
|
|
239
|
+
}
|
|
240
|
+
if (definition.memory !== undefined) validateMemory(definition.memory);
|
|
241
|
+
if (definition.chrome !== undefined && !['portal', 'hidden'].includes(definition.chrome)) throw usageError("chrome is 'portal' (default) or 'hidden'.");
|
|
242
|
+
if (definition.markdown !== undefined && (typeof definition.markdown !== 'string' || !definition.markdown)) {
|
|
243
|
+
throw usageError('markdown names a source module whose default export renders this view as Markdown.');
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
...(version !== undefined ? { spec_version: version } : {}),
|
|
247
|
+
...(definition.path !== undefined ? { path: definition.path } : {}),
|
|
248
|
+
...(definition.params !== undefined ? { params } : {}),
|
|
249
|
+
...(definition.shows !== undefined ? { shows: definition.shows } : {}),
|
|
250
|
+
...(definition.memory !== undefined ? { memory: definition.memory } : {}),
|
|
251
|
+
...(definition.chrome !== undefined ? { chrome: definition.chrome } : {}),
|
|
252
|
+
...(definition.markdown !== undefined ? { markdown: { export_name: 'SpaceMarkdown' } } : {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -38,15 +38,17 @@ export function resolveStoreScreenshotAccent(accent, seed = 'notis-app') {
|
|
|
38
38
|
return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function storeScreenshotLayout(width = 2000, height = 1250) {
|
|
41
|
+
export function storeScreenshotLayout(width = 2000, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
|
|
42
|
+
const aspect = Number.isFinite(contentAspect) && contentAspect > 0
|
|
43
|
+
? contentAspect : STORE_SCREENSHOT_ASPECT;
|
|
42
44
|
const horizontalInset = Math.max(48, Math.round(width * 0.06));
|
|
43
45
|
const verticalInset = Math.max(40, Math.round(height * 0.06));
|
|
44
46
|
let cardWidth = width - horizontalInset * 2;
|
|
45
|
-
let cardHeight = Math.round(cardWidth /
|
|
47
|
+
let cardHeight = Math.round(cardWidth / aspect);
|
|
46
48
|
const maximumCardHeight = height - verticalInset * 2;
|
|
47
49
|
if (cardHeight > maximumCardHeight) {
|
|
48
50
|
cardHeight = maximumCardHeight;
|
|
49
|
-
cardWidth = Math.round(cardHeight *
|
|
51
|
+
cardWidth = Math.round(cardHeight * aspect);
|
|
50
52
|
}
|
|
51
53
|
const cardLeft = Math.round((width - cardWidth) / 2);
|
|
52
54
|
const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 0.008);
|
|
@@ -80,7 +82,7 @@ function roundedRectSvg(width, height, radius, { fill = '#ffffff', stroke = null
|
|
|
80
82
|
|
|
81
83
|
/**
|
|
82
84
|
* Composite a truthful browser capture into the deterministic Store frame.
|
|
83
|
-
*
|
|
85
|
+
* Fit the complete source pixels; never crop controls or redraw the app UI.
|
|
84
86
|
*/
|
|
85
87
|
export async function composeStoreScreenshot({
|
|
86
88
|
inputPath,
|
|
@@ -94,13 +96,14 @@ export async function composeStoreScreenshot({
|
|
|
94
96
|
}) {
|
|
95
97
|
const sharp = await loadSharp();
|
|
96
98
|
const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
|
|
97
|
-
const
|
|
99
|
+
const source = await sharp(inputPath).metadata();
|
|
100
|
+
const layout = storeScreenshotLayout(width, height, source.width / source.height);
|
|
98
101
|
const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
|
|
99
102
|
|
|
100
103
|
const roundedCapture = await sharp(inputPath)
|
|
101
104
|
.resize(layout.cardWidth, layout.cardHeight, {
|
|
102
|
-
fit: '
|
|
103
|
-
|
|
105
|
+
fit: 'contain',
|
|
106
|
+
background: resolvedTheme === 'dark' ? '#09090b' : '#ffffff',
|
|
104
107
|
})
|
|
105
108
|
.composite([{
|
|
106
109
|
input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
|
|
@@ -30,8 +30,10 @@ export function reconcileAllSkills<T extends object>(options: {
|
|
|
30
30
|
serverUrl: string,
|
|
31
31
|
jwt: string,
|
|
32
32
|
dependencies: Record<string, never>,
|
|
33
|
-
options: { honorSyncEnabled: boolean },
|
|
33
|
+
options: { honorSyncEnabled: boolean; syncHome?: string; linkAgentFolders?: boolean },
|
|
34
34
|
) => Promise<T>;
|
|
35
35
|
reconcileBase?: (options: { userId?: string | null; home?: string }) => BaseSkillReconcileResult;
|
|
36
36
|
lockOptions?: Omit<SkillSyncLockOptions, 'home'>;
|
|
37
|
+
/** False syncs the account's own folder only; another account owns the agent folders. */
|
|
38
|
+
linkAgentFolders?: boolean;
|
|
37
39
|
}): Promise<T & ReconcileAllSkillsResult>;
|
|
@@ -211,14 +211,22 @@ export async function reconcileAllSkills({
|
|
|
211
211
|
runAccountSync,
|
|
212
212
|
reconcileBase = reconcileBaseSkills,
|
|
213
213
|
lockOptions = {},
|
|
214
|
+
linkAgentFolders = true,
|
|
214
215
|
}) {
|
|
215
216
|
return withSkillSyncLock(async () => {
|
|
216
|
-
|
|
217
|
+
// Base skills are linked from the account that owns the agent folders.
|
|
218
|
+
const base = linkAgentFolders
|
|
219
|
+
? reconcileBase({ userId, ...(home ? { home } : {}) })
|
|
220
|
+
: { skills: [], installed: 0, linked: 0, unchanged: 0, backups: [] };
|
|
217
221
|
const account = await runAccountSync(
|
|
218
222
|
serverUrl,
|
|
219
223
|
jwt,
|
|
220
224
|
{},
|
|
221
|
-
{
|
|
225
|
+
{
|
|
226
|
+
honorSyncEnabled,
|
|
227
|
+
...(home ? { syncHome: home } : {}),
|
|
228
|
+
...(linkAgentFolders ? {} : { linkAgentFolders: false }),
|
|
229
|
+
},
|
|
222
230
|
);
|
|
223
231
|
return {
|
|
224
232
|
...account,
|
package/src/runtime/transport.js
CHANGED
|
@@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
5
|
-
import { delegatedContextReason } from './delegated-context.js';
|
|
6
5
|
import {
|
|
7
6
|
credentialIsExpired,
|
|
8
7
|
getJwtExpiration,
|
|
@@ -82,10 +81,15 @@ function createMultipartFileUpload(payload, bindingMetadata, fileBindings) {
|
|
|
82
81
|
};
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
function normalizeBackendError(status, payload, runtime) {
|
|
84
|
+
export function normalizeBackendError(status, payload, runtime) {
|
|
86
85
|
const backendError = payload?.error;
|
|
86
|
+
// Space errors answer {error: <stable code>, message: <readable text>}: show
|
|
87
|
+
// the text (it can name the Space involved); the code stays in details.
|
|
88
|
+
const codedMessage = typeof backendError === 'string' && /^[a-z][a-z0-9_]*$/.test(backendError)
|
|
89
|
+
&& typeof payload?.message === 'string' && payload.message ? payload.message : null;
|
|
87
90
|
const message =
|
|
88
91
|
backendError?.message ||
|
|
92
|
+
codedMessage ||
|
|
89
93
|
backendError ||
|
|
90
94
|
payload?.message ||
|
|
91
95
|
payload?.error ||
|
|
@@ -258,6 +262,7 @@ export async function httpRequest({
|
|
|
258
262
|
body,
|
|
259
263
|
multipart = false,
|
|
260
264
|
requireAuth = true,
|
|
265
|
+
spacesProtocol,
|
|
261
266
|
}) {
|
|
262
267
|
// Refresh before spending the credential rather than after a rejection: the
|
|
263
268
|
// rotating refresh token is shared with every other `notis` process reading
|
|
@@ -293,6 +298,7 @@ export async function httpRequest({
|
|
|
293
298
|
'X-Notis-CLI-Version': runtime.cliVersion,
|
|
294
299
|
'X-Notis-Request-Id': requestId,
|
|
295
300
|
};
|
|
301
|
+
if (spacesProtocol !== undefined) headers['X-Notis-Spaces-Protocol'] = String(spacesProtocol);
|
|
296
302
|
if (!multipart) {
|
|
297
303
|
headers['Content-Type'] = 'application/json';
|
|
298
304
|
}
|
|
@@ -441,10 +447,6 @@ export async function callTool({
|
|
|
441
447
|
cwd: process.cwd(),
|
|
442
448
|
agent_mode: runtime.agentMode,
|
|
443
449
|
cli_version: runtime.cliVersion,
|
|
444
|
-
// Reported so the server can refuse work-handing tools from a run Notis
|
|
445
|
-
// is itself driving. Advisory -- a determined agent can unset the markers
|
|
446
|
-
// -- so the server treats it as one layer, not the whole guard.
|
|
447
|
-
...(delegatedContextReason() ? { delegated_context: true } : {}),
|
|
448
450
|
...(runtime.debugEntitlementOverride
|
|
449
451
|
? { debug_entitlement_override: runtime.debugEntitlementOverride }
|
|
450
452
|
: {}),
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, writeFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { httpRequest } from './transport.js';
|
|
6
|
+
import { CliError, EXIT_CODES, usageError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
async function rendererLibrary() {
|
|
9
|
+
const packaged = new URL('../../dist/view-renderer/src/index.js', import.meta.url);
|
|
10
|
+
if (existsSync(fileURLToPath(packaged))) return import(packaged.href);
|
|
11
|
+
const development = new URL('../../../view-renderer/src/index.js', import.meta.url);
|
|
12
|
+
if (existsSync(fileURLToPath(development))) return import(development.href);
|
|
13
|
+
throw usageError('The shared view renderer is missing. Rebuild or reinstall this CLI package.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Root command registration supplies flags/output formatting; this adapter holds no browser JWT. */
|
|
17
|
+
export async function renderViewWithCliRuntime({ runtime, url, outputs = ['markdown', 'screenshot'], width = 1440,
|
|
18
|
+
directory, signal, loadLibrary = rendererLibrary, request = httpRequest }) {
|
|
19
|
+
if (typeof url !== 'string' || !url.trim() || ![390, 1440].includes(width)) throw usageError('Choose a view link and width 390 or 1440.');
|
|
20
|
+
const ticket = (await request({ runtime, method: 'POST', path: '/portal_view_render/prepare', spacesProtocol: 1,
|
|
21
|
+
body: { url, outputs, width }, signal })).payload;
|
|
22
|
+
if (typeof ticket?.credential !== 'string') throw usageError('The short-lived render ticket is missing.');
|
|
23
|
+
const library = await loadLibrary();
|
|
24
|
+
const broker = library.createRemoteRenderBroker({ apiBase: runtime.apiBase, credential: ticket.credential });
|
|
25
|
+
let result;
|
|
26
|
+
try {
|
|
27
|
+
result = await library.renderView({ broker, outputs, width, signal });
|
|
28
|
+
await broker.complete(result, { signal });
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof library.ViewRenderError) throw new CliError({ code: error.code,
|
|
32
|
+
message: error.message, exitCode: EXIT_CODES.backend, retryable: error.status >= 500 || error.status === 429, cause: error });
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
if (!directory) return { result, artifacts: null };
|
|
36
|
+
const root = resolve(directory), created = [], artifacts = {};
|
|
37
|
+
await mkdir(root, { recursive: true });
|
|
38
|
+
try {
|
|
39
|
+
for (const [kind, name, bytes] of [
|
|
40
|
+
['markdown', 'view.md', result.markdown === undefined ? undefined : Buffer.from(result.markdown, 'utf8')],
|
|
41
|
+
['screenshot', 'view.png', result.screenshot ? Buffer.from(result.screenshot.data_base64, 'base64') : undefined],
|
|
42
|
+
]) {
|
|
43
|
+
if (bytes === undefined) continue;
|
|
44
|
+
const path = join(root, name);
|
|
45
|
+
await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }); created.push(path); artifacts[kind] = path;
|
|
46
|
+
}
|
|
47
|
+
} catch (error) { await Promise.allSettled(created.map(path => unlink(path))); throw error; }
|
|
48
|
+
return { result, artifacts };
|
|
49
|
+
}
|
package/src/skill-sync-worker.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { resolveRuntimeProfile } from './runtime/profiles.js';
|
|
4
|
+
import { getCurrentProfileName, loadConfig, resolveRuntimeProfile } from './runtime/profiles.js';
|
|
5
5
|
import { ensureFreshOAuthCredential } from './runtime/oauth.js';
|
|
6
6
|
import { runSkillSync, fetchSyncSettings } from '../dist/skill-sync/index.js';
|
|
7
7
|
import { withSkillSyncLock } from './runtime/sync-skills.js';
|
|
8
8
|
|
|
9
|
+
function currentCliProfile() {
|
|
10
|
+
try {
|
|
11
|
+
return getCurrentProfileName(loadConfig());
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
export async function runAutomaticSkillSync({ profile, apiBase, userId }, {
|
|
10
18
|
resolveRuntime = resolveRuntimeProfile, refresh = ensureFreshOAuthCredential,
|
|
11
19
|
settings = fetchSyncSettings, sync = runSkillSync, lock = withSkillSyncLock,
|
|
20
|
+
currentProfile = currentCliProfile,
|
|
12
21
|
} = {}) {
|
|
13
22
|
const runtime = resolveRuntime({ profile }, { requireAuth: true });
|
|
14
23
|
if (runtime.credentialKind !== 'oauth' || runtime.apiBase !== apiBase
|
|
@@ -18,9 +27,12 @@ export async function runAutomaticSkillSync({ profile, apiBase, userId }, {
|
|
|
18
27
|
if (saved.user_id !== userId) throw new Error('Automatic skill sync identity mismatch');
|
|
19
28
|
// Do not gather, unlink foreign accounts or write anything when opted out.
|
|
20
29
|
if (!saved.sync_enabled) return { status: 'disabled' };
|
|
30
|
+
// The service stays bound to its profile; after `notis profile use` moves the
|
|
31
|
+
// default away, it keeps that account's folder in sync without linking it.
|
|
32
|
+
const linkAgentFolders = (currentProfile() ?? profile) === profile;
|
|
21
33
|
const result = await lock(() => sync(runtime.apiBase, runtime.jwt, {
|
|
22
34
|
fetchSyncSettings: async () => saved,
|
|
23
|
-
}, { honorSyncEnabled: true }));
|
|
35
|
+
}, { honorSyncEnabled: true, ...(linkAgentFolders ? {} : { linkAgentFolders: false }) }));
|
|
24
36
|
return { status: (result.failedLinks?.length || result.failedPushes?.length) ? 'partial' : 'synced', ...result };
|
|
25
37
|
}
|
|
26
38
|
|