@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.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 +433 -133
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
- package/dist/base-skills/notis-apps/SKILL.md +70 -0
- package/dist/base-skills/notis-apps/references/architecture.md +164 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +165 -0
- package/dist/base-skills/notis-apps/references/reading.md +89 -0
- package/dist/base-skills/notis-apps/references/release.md +99 -0
- package/dist/base-skills/notis-apps/references/sdk.md +62 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
- package/dist/base-skills/notis-cli/SKILL.md +140 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +67 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +1626 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +2990 -0
- package/package.json +16 -6
- package/skills/notis-apps/cli.md +313 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +129 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1470 -202
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +716 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +84 -82
- package/src/command-specs/index.js +25 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/reports.js +86 -0
- package/src/command-specs/skills.js +75 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/tools.js +455 -139
- package/src/runtime/agent-browser.js +632 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +404 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-platform.js +2633 -210
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +292 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/skill-sync/cloud-client.ts +99 -0
- package/src/runtime/skill-sync/index.ts +697 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +110 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/store-screenshot.js +143 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +324 -45
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/.harness/index.html.tmpl +430 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/layout.tsx +5 -2
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +4137 -0
- package/template/package.json +5 -5
- package/template/packages/{notis-sdk → sdk}/package.json +13 -3
- package/template/packages/sdk/src/agentContext.ts +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +257 -0
- package/template/packages/sdk/src/documents.ts +256 -0
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useTool.ts +65 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +161 -0
- package/template/packages/sdk/src/interactions/actions.ts +59 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +45 -0
- package/template/packages/sdk/src/provider.tsx +44 -0
- package/template/packages/sdk/src/queryCache.ts +170 -0
- package/template/packages/sdk/src/runtime.ts +451 -0
- package/template/packages/sdk/src/styles.css +213 -0
- package/template/packages/sdk/src/tailwind.ts +56 -0
- package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
- package/template/tailwind.config.ts +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { isValidProfileName, loadConfig } from './profiles.js';
|
|
8
|
+
import { withSkillSyncLock } from './sync-skills.js';
|
|
9
|
+
|
|
10
|
+
export const SKILL_SYNC_SERVICE_LABEL = 'ai.notis.skills-sync';
|
|
11
|
+
const bundlePath = fileURLToPath(new URL('../../dist/skill-sync-worker.mjs', import.meta.url));
|
|
12
|
+
|
|
13
|
+
function atomicWrite(target, value, mode = 0o600) {
|
|
14
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
15
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
16
|
+
writeFileSync(temporary, value, { mode });
|
|
17
|
+
renameSync(temporary, target);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function xml(value) {
|
|
21
|
+
return String(value).replaceAll('&', '&').replaceAll('<', '<')
|
|
22
|
+
.replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function maybeInstallSkillSyncService(runtime, {
|
|
26
|
+
config = loadConfig(), install = installSkillSyncService,
|
|
27
|
+
fetchSettings, refresh,
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (process.platform !== 'darwin' || runtime.credentialKind !== 'oauth'
|
|
30
|
+
|| config.current_profile !== runtime.profileName
|
|
31
|
+
|| !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) return;
|
|
32
|
+
// Ordinary commands must not steal another explicitly bound account's job.
|
|
33
|
+
const plist = join(homedir(), 'Library', 'LaunchAgents', `${SKILL_SYNC_SERVICE_LABEL}.plist`);
|
|
34
|
+
if (existsSync(plist)) {
|
|
35
|
+
const saved = readFileSync(plist, 'utf8');
|
|
36
|
+
if (runtime.oauthUserId && [runtime.profileName, runtime.apiBase, runtime.oauthUserId]
|
|
37
|
+
.every(value => saved.includes(`<string>${xml(value)}</string>`))) return install(runtime);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const refreshCredential = refresh || (await import('./oauth.js')).ensureFreshOAuthCredential;
|
|
41
|
+
await refreshCredential(runtime);
|
|
42
|
+
const getSettings = fetchSettings || (await import('../../dist/skill-sync/index.js')).fetchSyncSettings;
|
|
43
|
+
const settings = await getSettings(runtime.apiBase, runtime.jwt);
|
|
44
|
+
if (settings.sync_enabled && settings.user_id === runtime.oauthUserId) return install(runtime);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function installSkillSyncService(runtime, options = {}) {
|
|
48
|
+
if ((options.platform || process.platform) !== 'darwin') return { status: 'unsupported_platform' };
|
|
49
|
+
if (runtime.credentialKind !== 'oauth' || !runtime.oauthUserId
|
|
50
|
+
|| !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) {
|
|
51
|
+
return { status: 'skipped_non_personal_profile' };
|
|
52
|
+
}
|
|
53
|
+
// Registration upgrades must not kill a worker holding the filesystem lock,
|
|
54
|
+
// nor race another CLI registering the same LaunchAgent.
|
|
55
|
+
return withSkillSyncLock(() => installSkillSyncServiceLocked(runtime, options),
|
|
56
|
+
options.home ? { home: options.home } : {});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function installSkillSyncServiceLocked(runtime, {
|
|
60
|
+
home = homedir(), platform = process.platform, nodePath = process.execPath,
|
|
61
|
+
source = bundlePath, run = spawnSync, uid = process.getuid?.(),
|
|
62
|
+
} = {}) {
|
|
63
|
+
// A single explicitly selected personal account owns global agent folders.
|
|
64
|
+
// Development and hosted credentials must never enroll a machine-wide job.
|
|
65
|
+
if (platform !== 'darwin') return { status: 'unsupported_platform' };
|
|
66
|
+
if (runtime.credentialKind !== 'oauth' || runtime.envCredentialOverride
|
|
67
|
+
|| !isValidProfileName(runtime.profileName)
|
|
68
|
+
|| !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) {
|
|
69
|
+
return { status: 'skipped_non_personal_profile' };
|
|
70
|
+
}
|
|
71
|
+
const root = join(home, '.notis', 'skills', 'service');
|
|
72
|
+
const bundle = readFileSync(source);
|
|
73
|
+
const digest = createHash('sha256').update(bundle).digest('hex');
|
|
74
|
+
const installedBundle = join(root, 'runtime', digest, 'worker.mjs');
|
|
75
|
+
if (!existsSync(installedBundle)
|
|
76
|
+
|| !readFileSync(installedBundle).equals(bundle)) atomicWrite(installedBundle, bundle, 0o500);
|
|
77
|
+
const args = [nodePath, installedBundle, runtime.profileName, runtime.apiBase, runtime.oauthUserId];
|
|
78
|
+
if (!runtime.oauthUserId) return { status: 'skipped_missing_account' };
|
|
79
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
80
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
81
|
+
<plist version="1.0"><dict>
|
|
82
|
+
<key>Label</key><string>${SKILL_SYNC_SERVICE_LABEL}</string>
|
|
83
|
+
<key>ProgramArguments</key><array>${args.map(value => `<string>${xml(value)}</string>`).join('')}</array>
|
|
84
|
+
<key>WorkingDirectory</key><string>${xml(root)}</string>
|
|
85
|
+
<key>StartInterval</key><integer>60</integer>
|
|
86
|
+
<key>ProcessType</key><string>Background</string>
|
|
87
|
+
</dict></plist>
|
|
88
|
+
`;
|
|
89
|
+
const plistPath = join(home, 'Library', 'LaunchAgents', `${SKILL_SYNC_SERVICE_LABEL}.plist`);
|
|
90
|
+
const domain = `gui/${uid}`;
|
|
91
|
+
const target = `${domain}/${SKILL_SYNC_SERVICE_LABEL}`;
|
|
92
|
+
const unchanged = existsSync(plistPath) && readFileSync(plistPath, 'utf8') === plist;
|
|
93
|
+
const loaded = run('/bin/launchctl', ['print', target], { encoding: 'utf8', timeout: 5000 });
|
|
94
|
+
if (unchanged && loaded.status === 0) return { status: 'installed', intervalSeconds: 60, profile: runtime.profileName };
|
|
95
|
+
if (loaded.status === 0) {
|
|
96
|
+
const stopped = run('/bin/launchctl', ['bootout', target], { encoding: 'utf8', timeout: 5000 });
|
|
97
|
+
if (stopped.status !== 0) throw new Error('Could not update the existing automatic skill sync job');
|
|
98
|
+
}
|
|
99
|
+
atomicWrite(plistPath, plist);
|
|
100
|
+
let started;
|
|
101
|
+
// launchd may finish tearing down an old registration after bootout returns.
|
|
102
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
103
|
+
started = run('/bin/launchctl', ['bootstrap', domain, plistPath], { encoding: 'utf8', timeout: 5000 });
|
|
104
|
+
if (started.status === 0) break;
|
|
105
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
106
|
+
}
|
|
107
|
+
if (started?.status !== 0) throw new Error('Could not register automatic skill sync with macOS');
|
|
108
|
+
return { status: 'installed', intervalSeconds: 60, profile: runtime.profileName };
|
|
109
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
|
|
3
|
+
const STORE_SCREENSHOT_ASPECT = 16 / 10;
|
|
4
|
+
const SHARED_BACKDROP_PATH = fileURLToPath(
|
|
5
|
+
new URL('./assets/store-screenshot-dark.png', import.meta.url),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const ACCENT_PALETTES = {
|
|
9
|
+
blue: { glow: '#4f7cff', glow2: '#274690' },
|
|
10
|
+
violet: { glow: '#9b87f5', glow2: '#5038a1' },
|
|
11
|
+
emerald: { glow: '#34d399', glow2: '#176b55' },
|
|
12
|
+
amber: { glow: '#f2a94a', glow2: '#8b4f20' },
|
|
13
|
+
rose: { glow: '#fb7185', glow2: '#8f3048' },
|
|
14
|
+
sky: { glow: '#38bdf8', glow2: '#176887' },
|
|
15
|
+
fuchsia: { glow: '#d946ef', glow2: '#772786' },
|
|
16
|
+
teal: { glow: '#2dd4bf', glow2: '#176e68' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ACCENT_NAMES = Object.keys(ACCENT_PALETTES);
|
|
20
|
+
|
|
21
|
+
async function loadSharp() {
|
|
22
|
+
return (await import('sharp')).default;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stableHash(value) {
|
|
26
|
+
let hash = 2166136261;
|
|
27
|
+
for (const character of String(value || 'notis-app')) {
|
|
28
|
+
hash ^= character.codePointAt(0);
|
|
29
|
+
hash = Math.imul(hash, 16777619);
|
|
30
|
+
}
|
|
31
|
+
return hash >>> 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveStoreScreenshotAccent(accent, seed = 'notis-app') {
|
|
35
|
+
if (accent && ACCENT_PALETTES[accent]) {
|
|
36
|
+
return accent;
|
|
37
|
+
}
|
|
38
|
+
return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function storeScreenshotLayout(width = 2000, height = 1250) {
|
|
42
|
+
const horizontalInset = Math.max(48, Math.round(width * 0.06));
|
|
43
|
+
const verticalInset = Math.max(40, Math.round(height * 0.06));
|
|
44
|
+
let cardWidth = width - horizontalInset * 2;
|
|
45
|
+
let cardHeight = Math.round(cardWidth / STORE_SCREENSHOT_ASPECT);
|
|
46
|
+
const maximumCardHeight = height - verticalInset * 2;
|
|
47
|
+
if (cardHeight > maximumCardHeight) {
|
|
48
|
+
cardHeight = maximumCardHeight;
|
|
49
|
+
cardWidth = Math.round(cardHeight * STORE_SCREENSHOT_ASPECT);
|
|
50
|
+
}
|
|
51
|
+
const cardLeft = Math.round((width - cardWidth) / 2);
|
|
52
|
+
const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 0.008);
|
|
53
|
+
return {
|
|
54
|
+
width,
|
|
55
|
+
height,
|
|
56
|
+
cardWidth,
|
|
57
|
+
cardHeight,
|
|
58
|
+
cardLeft,
|
|
59
|
+
cardTop,
|
|
60
|
+
radius: Math.max(18, Math.round(width * 0.016)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function backdropImage(layout) {
|
|
65
|
+
const sharp = await loadSharp();
|
|
66
|
+
return sharp(SHARED_BACKDROP_PATH)
|
|
67
|
+
.resize(layout.width, layout.height, { fit: 'cover', position: 'centre' })
|
|
68
|
+
.png()
|
|
69
|
+
.toBuffer();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function roundedRectSvg(width, height, radius, { fill = '#ffffff', stroke = null } = {}) {
|
|
73
|
+
const strokeAttribute = stroke
|
|
74
|
+
? ` fill="none" stroke="${stroke}" stroke-width="2"`
|
|
75
|
+
: ` fill="${fill}"`;
|
|
76
|
+
return Buffer.from(
|
|
77
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="${radius}" ry="${radius}"${strokeAttribute}/></svg>`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Composite a truthful browser capture into the deterministic Store frame.
|
|
83
|
+
* The source pixels are only resized/cropped; the app UI is never redrawn.
|
|
84
|
+
*/
|
|
85
|
+
export async function composeStoreScreenshot({
|
|
86
|
+
inputPath,
|
|
87
|
+
outputPath,
|
|
88
|
+
width = 2000,
|
|
89
|
+
height = 1250,
|
|
90
|
+
accent = null,
|
|
91
|
+
seed = 'notis-app',
|
|
92
|
+
focused = false,
|
|
93
|
+
theme = 'light',
|
|
94
|
+
}) {
|
|
95
|
+
const sharp = await loadSharp();
|
|
96
|
+
const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
|
|
97
|
+
const layout = storeScreenshotLayout(width, height);
|
|
98
|
+
const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
|
|
99
|
+
|
|
100
|
+
const roundedCapture = await sharp(inputPath)
|
|
101
|
+
.resize(layout.cardWidth, layout.cardHeight, {
|
|
102
|
+
fit: 'cover',
|
|
103
|
+
position: focused ? 'top' : 'centre',
|
|
104
|
+
})
|
|
105
|
+
.composite([{
|
|
106
|
+
input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
|
|
107
|
+
blend: 'dest-in',
|
|
108
|
+
}])
|
|
109
|
+
.png()
|
|
110
|
+
.toBuffer();
|
|
111
|
+
|
|
112
|
+
const border = roundedRectSvg(
|
|
113
|
+
layout.cardWidth,
|
|
114
|
+
layout.cardHeight,
|
|
115
|
+
layout.radius,
|
|
116
|
+
{ stroke: resolvedTheme === 'dark' ? '#FFFFFF2A' : '#0F172A22' },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
await sharp(await backdropImage(layout))
|
|
120
|
+
.composite([
|
|
121
|
+
{ input: roundedCapture, left: layout.cardLeft, top: layout.cardTop },
|
|
122
|
+
{ input: border, left: layout.cardLeft, top: layout.cardTop },
|
|
123
|
+
])
|
|
124
|
+
.png({
|
|
125
|
+
compressionLevel: 9,
|
|
126
|
+
adaptiveFiltering: true,
|
|
127
|
+
})
|
|
128
|
+
.toFile(outputPath);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
mode: 'framed',
|
|
132
|
+
accent: resolvedAccent,
|
|
133
|
+
focused: Boolean(focused),
|
|
134
|
+
theme: resolvedTheme,
|
|
135
|
+
card: {
|
|
136
|
+
left: layout.cardLeft,
|
|
137
|
+
top: layout.cardTop,
|
|
138
|
+
width: layout.cardWidth,
|
|
139
|
+
height: layout.cardHeight,
|
|
140
|
+
radius: layout.radius,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { BaseSkillReconcileResult } from './base-skills.js';
|
|
2
|
+
|
|
3
|
+
export interface ReconcileAllSkillsResult {
|
|
4
|
+
baseSkills: string[];
|
|
5
|
+
baseInstalled: number;
|
|
6
|
+
baseLinked: number;
|
|
7
|
+
baseBackups: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SkillSyncLockOptions {
|
|
11
|
+
home?: string;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
staleMs?: number;
|
|
14
|
+
pollMs?: number;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function withSkillSyncLock<T>(
|
|
19
|
+
callback: () => Promise<T> | T,
|
|
20
|
+
options?: SkillSyncLockOptions,
|
|
21
|
+
): Promise<T>;
|
|
22
|
+
|
|
23
|
+
export function reconcileAllSkills<T extends object>(options: {
|
|
24
|
+
serverUrl: string;
|
|
25
|
+
jwt: string;
|
|
26
|
+
honorSyncEnabled: boolean;
|
|
27
|
+
userId?: string | null;
|
|
28
|
+
home?: string;
|
|
29
|
+
runAccountSync: (
|
|
30
|
+
serverUrl: string,
|
|
31
|
+
jwt: string,
|
|
32
|
+
dependencies: Record<string, never>,
|
|
33
|
+
options: { honorSyncEnabled: boolean },
|
|
34
|
+
) => Promise<T>;
|
|
35
|
+
reconcileBase?: (options: { userId?: string | null; home?: string }) => BaseSkillReconcileResult;
|
|
36
|
+
lockOptions?: Omit<SkillSyncLockOptions, 'home'>;
|
|
37
|
+
}): Promise<T & ReconcileAllSkillsResult>;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { reconcileBaseSkills } from './base-skills.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
|
9
|
+
const DEFAULT_LOCK_STALE_MS = 10 * 60_000;
|
|
10
|
+
const DEFAULT_LOCK_POLL_MS = 50;
|
|
11
|
+
|
|
12
|
+
async function lockSnapshot(lockDirectory) {
|
|
13
|
+
try {
|
|
14
|
+
const [raw, metadata] = await Promise.all([
|
|
15
|
+
readFile(join(lockDirectory, 'owner'), 'utf8').catch(() => ''),
|
|
16
|
+
stat(lockDirectory),
|
|
17
|
+
]);
|
|
18
|
+
let owner = {};
|
|
19
|
+
try {
|
|
20
|
+
owner = raw ? JSON.parse(raw) : {};
|
|
21
|
+
} catch {
|
|
22
|
+
// A process can die between mkdir and the atomic owner write. Preserve
|
|
23
|
+
// the directory mtime as a reclaimable ownerless lease.
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
id: typeof owner.id === 'string' ? owner.id : null,
|
|
27
|
+
pid: Number.isInteger(Number(owner.pid)) ? Number(owner.pid) : null,
|
|
28
|
+
at: Number.isFinite(Number(owner.at)) ? Number(owner.at) : metadata.mtimeMs,
|
|
29
|
+
mtimeMs: metadata.mtimeMs,
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sameLockSnapshot(left, right) {
|
|
37
|
+
return Boolean(
|
|
38
|
+
left
|
|
39
|
+
&& right
|
|
40
|
+
&& left.id === right.id
|
|
41
|
+
&& left.pid === right.pid
|
|
42
|
+
&& left.at === right.at
|
|
43
|
+
&& left.mtimeMs === right.mtimeMs,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function processIsAlive(pid) {
|
|
48
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return error?.code === 'EPERM';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function delay(milliseconds) {
|
|
58
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function quarantineStaleLock(lockDirectory, snapshot) {
|
|
62
|
+
const quarantineRoot = join(dirname(lockDirectory), '.stale-operation-locks');
|
|
63
|
+
const snapshotToken = createHash('sha256')
|
|
64
|
+
.update(JSON.stringify(snapshot))
|
|
65
|
+
.digest('hex');
|
|
66
|
+
await mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
|
|
67
|
+
try {
|
|
68
|
+
// The deterministic destination is the compare-and-swap guard. If two
|
|
69
|
+
// waiters observed the same stale owner, only one can move it here. The
|
|
70
|
+
// retained non-empty tombstone prevents the loser from ever moving a new
|
|
71
|
+
// live lock that appeared at the shared pathname in the meantime.
|
|
72
|
+
await rename(lockDirectory, join(quarantineRoot, snapshotToken));
|
|
73
|
+
return true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (['EEXIST', 'ENOTEMPTY', 'ENOENT'].includes(error?.code)) return false;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function writeLockOwnerAtomically(lockDirectory, owner) {
|
|
81
|
+
const temporaryOwnerPath = join(lockDirectory, `.owner.${owner.id}.tmp`);
|
|
82
|
+
const ownerPath = join(lockDirectory, 'owner');
|
|
83
|
+
await writeFile(temporaryOwnerPath, JSON.stringify(owner), { mode: 0o600 });
|
|
84
|
+
await rename(temporaryOwnerPath, ownerPath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function releaseOwnedLock(lockDirectory, ownerId) {
|
|
88
|
+
const owner = await lockSnapshot(lockDirectory);
|
|
89
|
+
if (owner?.id !== ownerId) return;
|
|
90
|
+
|
|
91
|
+
const quarantineRoot = join(dirname(lockDirectory), '.stale-operation-locks');
|
|
92
|
+
const releasedDirectory = join(quarantineRoot, `released.${ownerId}`);
|
|
93
|
+
await mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
|
|
94
|
+
try {
|
|
95
|
+
// Moving the owned directory releases the shared pathname atomically.
|
|
96
|
+
// Delete only the private destination so a waiter can safely acquire a new
|
|
97
|
+
// lock without racing this owner's recursive cleanup.
|
|
98
|
+
await rename(lockDirectory, releasedDirectory);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code === 'ENOENT') return;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
await rm(releasedDirectory, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Serialize Desktop and terminal skill sync across processes on one Mac. */
|
|
107
|
+
export async function withSkillSyncLock(callback, {
|
|
108
|
+
home = homedir(),
|
|
109
|
+
timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
|
|
110
|
+
staleMs = DEFAULT_LOCK_STALE_MS,
|
|
111
|
+
pollMs = DEFAULT_LOCK_POLL_MS,
|
|
112
|
+
now = () => Date.now(),
|
|
113
|
+
} = {}) {
|
|
114
|
+
const lockDirectory = join(home, '.notis', 'skills', '.operation-lock');
|
|
115
|
+
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
116
|
+
const deadline = now() + timeoutMs;
|
|
117
|
+
await mkdir(dirname(lockDirectory), { recursive: true, mode: 0o700 });
|
|
118
|
+
|
|
119
|
+
for (;;) {
|
|
120
|
+
try {
|
|
121
|
+
await mkdir(lockDirectory, { mode: 0o700 });
|
|
122
|
+
try {
|
|
123
|
+
await writeLockOwnerAtomically(lockDirectory, {
|
|
124
|
+
id: ownerId,
|
|
125
|
+
pid: process.pid,
|
|
126
|
+
at: now(),
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// This process exclusively created the directory and has not yet
|
|
130
|
+
// published an owner, so it is safe to undo a failed acquisition.
|
|
131
|
+
await rm(lockDirectory, { recursive: true, force: true });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const observed = await lockSnapshot(lockDirectory);
|
|
140
|
+
if (
|
|
141
|
+
observed
|
|
142
|
+
&& now() - observed.at > staleMs
|
|
143
|
+
&& !processIsAlive(observed.pid)
|
|
144
|
+
) {
|
|
145
|
+
// Require an unchanged observation interval before stealing. This avoids
|
|
146
|
+
// racing a holder that is publishing or refreshing its owner lease.
|
|
147
|
+
await delay(Math.max(pollMs, 10));
|
|
148
|
+
const current = await lockSnapshot(lockDirectory);
|
|
149
|
+
if (sameLockSnapshot(observed, current) && !processIsAlive(current?.pid)) {
|
|
150
|
+
if (await quarantineStaleLock(lockDirectory, current)) continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (now() >= deadline) {
|
|
154
|
+
throw new Error('Timed out waiting for another Notis skill sync to finish.');
|
|
155
|
+
}
|
|
156
|
+
await delay(pollMs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const heartbeatMs = Math.max(10, Math.min(30_000, Math.floor(staleMs / 3)));
|
|
160
|
+
let heartbeatStopped = false;
|
|
161
|
+
let heartbeatInFlight = Promise.resolve();
|
|
162
|
+
const refreshHeartbeat = async () => {
|
|
163
|
+
if (heartbeatStopped) return;
|
|
164
|
+
const temporaryOwnerPath = join(lockDirectory, `.owner.${ownerId}.tmp`);
|
|
165
|
+
await writeFile(
|
|
166
|
+
temporaryOwnerPath,
|
|
167
|
+
JSON.stringify({ id: ownerId, pid: process.pid, at: now() }),
|
|
168
|
+
{ mode: 0o600 },
|
|
169
|
+
);
|
|
170
|
+
const owner = await lockSnapshot(lockDirectory);
|
|
171
|
+
if (owner?.id !== ownerId) {
|
|
172
|
+
await rm(temporaryOwnerPath, { force: true });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// If a stale-lock reclaimer moved this directory after the ownership
|
|
176
|
+
// check, the source path disappears and rename fails instead of
|
|
177
|
+
// overwriting the owner of a newly acquired lock at the shared path.
|
|
178
|
+
await rename(temporaryOwnerPath, join(lockDirectory, 'owner'));
|
|
179
|
+
};
|
|
180
|
+
const heartbeat = setInterval(() => {
|
|
181
|
+
// Serialize refreshes and retain the active promise so cleanup cannot race
|
|
182
|
+
// a temporary owner write that was already in flight when the interval was
|
|
183
|
+
// cleared.
|
|
184
|
+
heartbeatInFlight = heartbeatInFlight
|
|
185
|
+
.then(refreshHeartbeat)
|
|
186
|
+
.catch(() => undefined);
|
|
187
|
+
}, heartbeatMs);
|
|
188
|
+
heartbeat.unref?.();
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
return await callback();
|
|
192
|
+
} finally {
|
|
193
|
+
heartbeatStopped = true;
|
|
194
|
+
clearInterval(heartbeat);
|
|
195
|
+
await heartbeatInFlight;
|
|
196
|
+
await releaseOwnedLock(lockDirectory, ownerId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Shared CLI-owned orchestration for base-skill installation plus account sync.
|
|
202
|
+
* Desktop injects the source engine that webpack bundles; the CLI command
|
|
203
|
+
* injects the generated dist engine used by the published package.
|
|
204
|
+
*/
|
|
205
|
+
export async function reconcileAllSkills({
|
|
206
|
+
serverUrl,
|
|
207
|
+
jwt,
|
|
208
|
+
honorSyncEnabled,
|
|
209
|
+
userId = null,
|
|
210
|
+
home,
|
|
211
|
+
runAccountSync,
|
|
212
|
+
reconcileBase = reconcileBaseSkills,
|
|
213
|
+
lockOptions = {},
|
|
214
|
+
}) {
|
|
215
|
+
return withSkillSyncLock(async () => {
|
|
216
|
+
const base = reconcileBase({ userId, ...(home ? { home } : {}) });
|
|
217
|
+
const account = await runAccountSync(
|
|
218
|
+
serverUrl,
|
|
219
|
+
jwt,
|
|
220
|
+
{},
|
|
221
|
+
{ honorSyncEnabled },
|
|
222
|
+
);
|
|
223
|
+
return {
|
|
224
|
+
...account,
|
|
225
|
+
baseSkills: base.skills,
|
|
226
|
+
baseInstalled: base.installed,
|
|
227
|
+
baseLinked: base.linked,
|
|
228
|
+
baseBackups: base.backups,
|
|
229
|
+
};
|
|
230
|
+
}, { ...lockOptions, ...(home ? { home } : {}) });
|
|
231
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const ERROR_CATEGORIES = new Map([
|
|
2
|
+
[2, 'usage'],
|
|
3
|
+
[3, 'auth'],
|
|
4
|
+
[4, 'network'],
|
|
5
|
+
[5, 'conflict'],
|
|
6
|
+
[6, 'backend'],
|
|
7
|
+
[7, 'unexpected'],
|
|
8
|
+
[8, 'payment'],
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export function cliDurationBucket(durationMs) {
|
|
12
|
+
if (durationMs < 1_000) return 'under_1s';
|
|
13
|
+
if (durationMs < 5_000) return '1s_to_5s';
|
|
14
|
+
if (durationMs < 30_000) return '5s_to_30s';
|
|
15
|
+
return 'over_30s';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function cliBackendKind(spec) {
|
|
19
|
+
const value = String(spec?.backend_call?.type || '').toLowerCase();
|
|
20
|
+
if (value === 'tool') return 'tool';
|
|
21
|
+
if (value === 'http') return 'http';
|
|
22
|
+
if (value === 'local') return 'local';
|
|
23
|
+
if (value.includes('health')) return 'health';
|
|
24
|
+
if (value.includes('local')) return 'local';
|
|
25
|
+
return 'other';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildCliTelemetryPayload({
|
|
29
|
+
spec,
|
|
30
|
+
runtime,
|
|
31
|
+
result,
|
|
32
|
+
durationMs,
|
|
33
|
+
error,
|
|
34
|
+
}) {
|
|
35
|
+
return {
|
|
36
|
+
command_id: spec.command_path.join('.'),
|
|
37
|
+
backend_kind: cliBackendKind(spec),
|
|
38
|
+
result,
|
|
39
|
+
error_category: result === 'failed'
|
|
40
|
+
? (ERROR_CATEGORIES.get(Number(error?.exitCode)) || 'unexpected')
|
|
41
|
+
: 'none',
|
|
42
|
+
duration_bucket: cliDurationBucket(durationMs),
|
|
43
|
+
cli_version: runtime.cliVersion,
|
|
44
|
+
agent_mode: Boolean(runtime.agentMode),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function reportCliCommand({
|
|
49
|
+
spec,
|
|
50
|
+
runtime,
|
|
51
|
+
result,
|
|
52
|
+
durationMs,
|
|
53
|
+
error = null,
|
|
54
|
+
fetchImpl = globalThis.fetch,
|
|
55
|
+
}) {
|
|
56
|
+
// Backend tool calls emit authoritative surface/tool telemetry server-side.
|
|
57
|
+
// This client event exists only for local commands that otherwise leave no
|
|
58
|
+
// observable runtime boundary.
|
|
59
|
+
if (
|
|
60
|
+
cliBackendKind(spec) !== 'local'
|
|
61
|
+
|| !runtime?.jwt
|
|
62
|
+
|| !runtime?.apiBase
|
|
63
|
+
|| typeof fetchImpl !== 'function'
|
|
64
|
+
) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const timeout = setTimeout(() => controller.abort(), 1_500);
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetchImpl(`${runtime.apiBase}/cli_telemetry`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: `Bearer ${runtime.jwt}`,
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'X-Notis-CLI-Version': runtime.cliVersion,
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(buildCliTelemetryPayload({
|
|
78
|
+
spec,
|
|
79
|
+
runtime,
|
|
80
|
+
result,
|
|
81
|
+
durationMs,
|
|
82
|
+
error,
|
|
83
|
+
})),
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
});
|
|
86
|
+
return response.ok;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
}
|
|
92
|
+
}
|