diff --git a/.changeset/cli-engine-only.md b/.changeset/cli-engine-only.md new file mode 100644 index 0000000..3ec8a23 --- /dev/null +++ b/.changeset/cli-engine-only.md @@ -0,0 +1,5 @@ +--- +'@vosjs/cli': minor +--- + +The CLI is now engine-only: `render` / `still` / `info` / `check` / `preview` / `versions`, local and account-free. The platform verbs (`fetch` / `push` / `pull`) moved to the vos plugin (`@vosso/vos-plugin`, npm), next to the service they talk to — installed plugin verbs surface through `vos ` exactly as before via a new delegate-on-unknown seam, appear in `vos help` through the plugin's manifest, and get a version row in `vos versions`. Existing installs keep working: the earlier plugin package names still resolve as fallbacks, and `vos voila ` remains a hidden alias. diff --git a/packages/cli/README.md b/packages/cli/README.md index 3e1fd6f..388839c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -13,32 +13,23 @@ npx vos render animation.json out.webm ## Commands ```bash -vos render [out] # config → video; a take directory renders via the take pipeline +vos render [out] # config → video; a take directory renders via the plugin vos still [out] # config → single frame (WebP) vos info # inspect a config +vos check # migrate → schema → syntax → compile → determinism/dialect lints, all local vos preview # serve a local playback page -vos versions # installed @vosjs/* versions - -# Take pipeline — screen recordings in, product video out (npm i -D @vosso/cli) -vos create --actions actions.json [out.webm] [--strict] # record + plan + render, one shot -vos record --actions actions.json [--out take] [--strict] -vos plan [--fresh] -vos frames [--at-zooms | --frame --size WxH] -vos open -vos validate - -# Platform (vos.so) — fetch a program, validate locally, push a private remix -vos fetch [--out dir] # writes config.json + meta.json (no auth for public programs) -vos check # migrate → schema → syntax → compile → determinism/dialect lints, all local -vos push [--vos id] [--title t] [--slug s] [--remix-of id] [--note n] [--label l] [--base versionId] [--overrides id,id] -vos pull [dir|take] [--vos id] [--since versionId] [--check] # what changed on vos.so since your base; syncs config + base +vos versions # installed @vosjs/* (and plugin) versions ``` -The take verbs delegate to the separately installed [`@vosso/cli`](https://www.npmjs.com/package/@vosso/cli) (its `run(argv)` export is the contract; `@vosso/voila-cli` remains an install fallback, and `vos voila ` keeps working as a hidden alias for existing scripts). `vos render` is polymorphic by a deterministic sniff, never a flag: a take directory is recognized by its `doc.json`; anything else renders as an engine config. +That is the whole engine surface: local, deterministic, no account, no auth — this package knows nothing about any hosting platform. Everything else — the take pipeline (screen recordings in, product video out: `create` / `record` / `plan` / `frames` / `open` / `validate`) and the vos.so platform verbs (`fetch` / `push` / `pull` / `login`) — ships as a separately installed plugin: -The platform verbs implement the iteration loop of the remix contract at [vos.so/llms-remix.txt](https://vos.so/llms-remix.txt): `fetch` a public program's config (params and presets preserved), edit it, `check` it locally (the same compiler the platform runs), and `push` it back as a **private** vos with lineage — or iterate an existing one with `--vos`. Auth resolves from `VOS_API_KEY`, then the first line of `~/.config/vos/credentials` (mint a key at [vos.so/app/api](https://vos.so/app/api); an ephemeral `vos_rg_` remix grant works too). Keys can never publish — the pushed vos stays private until a human publishes it on vos.so. A directory that has fetched or pushed TRACKS its vos through `meta.json`, so `--base` defaults to the version you actually edited from: a push against a moved head is rejected with the attributed, typed changelog of what changed there, and `vos pull` brings those changes down — versions with origin, label, note and a semantic summary, plus the `protected` set of human-edited nodes (keep their values unless the user asked; `--overrides` is the explicit consent). The previous local copy survives as `config.backup.json`. Take directories (recognized by `doc.json`) push and pull through the take pipeline in `@vosso/cli` automatically. `VOS_ORIGIN` overrides the platform origin for self-hosted or local development. +```bash +npm i -D @vosso/vos-plugin +``` + +Any verb this CLI does not own is forwarded to the plugin (its `run(argv)` export is the contract; the earlier package names `@vosso/cli` and `@vosso/voila-cli` still resolve as fallbacks, and `vos voila ` keeps working as a hidden alias for existing scripts). Installed plugin verbs appear in `vos help` via the plugin's manifest, and run as plain `vos ` — one binary either way. `vos render` is polymorphic by a deterministic sniff, never a flag: a take directory is recognized by its `doc.json`; anything else renders as an engine config. The plugin's verbs and the vos.so contracts are documented at [vos.so/llms.txt](https://vos.so/llms.txt). -`vos render` accepts `--width` / `--height` / `--fps` / `--duration` / `--format webm|mp4`; `vos still` accepts `--time` / `--width` / `--height`. Configs can be local files or URLs, and API `{ "config": … }` envelopes are unwrapped automatically. Old config versions are migrated before rendering. +`vos render` accepts `--width` / `--height` / `--fps` / `--duration` / `--format webm|mp4`; `vos still` accepts `--time` / `--width` / `--height`. Configs can be local files or URLs, and API `{ "config": … }` envelopes are unwrapped automatically. Old config versions are migrated before rendering. `vos check` runs the full local validation pipeline — the same compiler a hosted push runs server-side, so a clean check is a config that will compile anywhere. Rendering runs the same deterministic pipeline everywhere: the config is compiled with `@vosjs/core`, wrapped in the engine's capture template, and encoded frame-by-frame (WebCodecs) in headless Chromium. Same input, same video — locally, in CI, or on a server. diff --git a/packages/cli/src/__tests__/platform.test.ts b/packages/cli/src/__tests__/platform.test.ts deleted file mode 100644 index 9f56eb4..0000000 --- a/packages/cli/src/__tests__/platform.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { deriveSlug, parseVosId } from '../platform' -import { UsageError } from '../args' - -describe('parseVosId', () => { - it('passes bare ids through', () => { - expect(parseVosId('abc-123_XYZ')).toBe('abc-123_XYZ') - }) - - it('extracts from watch URLs', () => { - expect(parseVosId('https://vos.so/vos/9f3a-id')).toBe('9f3a-id') - }) - - it('extracts from embed URLs', () => { - expect(parseVosId('https://vos.so/embed/vos/9f3a-id')).toBe('9f3a-id') - }) - - it('extracts from studio query URLs', () => { - expect(parseVosId('https://vos.so/studio?vos=9f3a-id')).toBe('9f3a-id') - // legacy stage links redirect with the query intact - expect(parseVosId('https://vos.so/stage?vos=9f3a-id')).toBe('9f3a-id') - }) - - it('rejects things that are neither', () => { - expect(() => parseVosId('https://vos.so/gallery')).toThrow(UsageError) - expect(() => parseVosId('not a slug!')).toThrow(UsageError) - }) -}) - -describe('deriveSlug', () => { - it('lowercases and hyphenates', () => { - expect(deriveSlug('Aurora Ribbons — dusk')).toBe('aurora-ribbons-dusk') - }) - - it('trims to the 50-char platform cap without a trailing hyphen', () => { - const slug = deriveSlug('x'.repeat(49) + ' y') - expect(slug.length).toBeLessThanOrEqual(50) - expect(slug.endsWith('-')).toBe(false) - }) - - it('never returns empty', () => { - expect(deriveSlug('!!!')).toBe('remix') - }) -}) diff --git a/packages/cli/src/__tests__/pull.test.ts b/packages/cli/src/__tests__/pull.test.ts deleted file mode 100644 index e9c640e..0000000 --- a/packages/cli/src/__tests__/pull.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { formatChanges, readMeta, writeMeta } from '../platform' - -describe('formatChanges', () => { - it('renders attributed lines with labels and notes', () => { - const lines = formatChanges([ - { - versionId: 'v-2', - versionNumber: 2, - origin: 'studio', - label: 'warmer', - note: 'nudged the palette after review', - summary: 'param hue 0.2 → 0.35; zoom z1 level 1.6 → 1.8', - }, - { versionNumber: 3, origin: 'agent', summary: 'speed span added 4–7s' }, - ]) - expect(lines).toEqual([ - 'v2 (studio · warmer): param hue 0.2 → 0.35; zoom z1 level 1.6 → 1.8', - ' note: nudged the palette after review', - 'v3 (agent): speed span added 4–7s', - ]) - }) - - it('tolerates sparse entries', () => { - expect(formatChanges([{}])).toEqual(['v? (unknown): ']) - }) -}) - -describe('meta tracking', () => { - let dir: string - afterEach(() => rmSync(dir, { recursive: true, force: true })) - - it('round-trips and merges patches', () => { - dir = mkdtempSync(join(tmpdir(), 'vos-meta-')) - expect(readMeta(dir)).toBeNull() - writeMeta(dir, { id: 'abc', currentVersionId: 'v1', title: 'T' }) - writeMeta(dir, { currentVersionId: 'v2' }) - const meta = readMeta(dir) - expect(meta).toMatchObject({ id: 'abc', currentVersionId: 'v2', title: 'T' }) - // pretty-printed on disk (human-inspectable, diff-friendly) - expect(readFileSync(join(dir, 'meta.json'), 'utf8')).toContain('\n "id"') - }) -}) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f873355..48cfb3d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { existsSync, readFileSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { createServer } from 'node:http' @@ -16,62 +16,30 @@ import { loadVosConfig, configDuration } from './loadConfig' import { launchBrowser, BrowserUnavailableError } from './browser' import { renderVideo, renderStill, previewPages } from './render' import { runCheck } from './check' -import { - PLATFORM_ORIGIN, - apiError, - apiJson, - deriveSlug, - formatChanges, - parseVosId, - readMeta, - resolveCredential, - writeMeta, - type VersionChange, -} from './platform' - -const BOOLEAN_FLAGS = new Set(['json', 'help', 'version', 'check']) - -const HELP = `vos — command line for the vos programmatic video engine (https://vos.so) - -Usage + +const BOOLEAN_FLAGS = new Set(['json', 'help', 'version']) + +const HELP_ENGINE = `vos — command line for the vos programmatic video engine (https://vos.so/engine) + +Engine verbs (local, no account, no network beyond the render page's CDN deps) vos render [out] [--width 1920] [--height 1080] [--fps 30] [--duration ] [--format webm|mp4] [--json] vos still [out.webp] [--time 0] [--width] [--height] [--json] vos info [--json] + vos check [--json] + migrate → schema → syntax → compile → determinism/dialect lints, all local vos preview [--port 0] vos versions [--json] +` -Platform (vos.so) — the iteration loop: fetch, edit, push, pull, repeat - vos fetch [--out dir] [--json] - writes config.json + meta.json (no auth needed for public programs) - vos check [--json] - migrate → schema → compile → determinism/dialect lints, all local - vos push [--vos id] [--title t] [--slug s] [--remix-of id] - [--note n] [--label l] [--base versionId] [--overrides id,id] [--json] - no --vos: create a PRIVATE vos (lineage from meta.json / --remix-of) - with --vos: add a version; the base defaults to the tracked one in - meta.json, so a stale push 409s WITH the changes made on the platform. - --overrides consents to touching protected (human-edited) nodes — - only when the user asked for that change. Take DIRECTORIES push - through the take pipeline (@vosso/cli) automatically. - vos pull [dir|take] [--vos id] [--since versionId] [--check] [--json] - what changed on vos.so since your base: attributed versions with - typed summaries + the protected node set. Syncs config.json to the - head (previous copy kept as config.backup.json) and repoints the - base — re-apply your edit on top, then push. --check reports only. - auth: VOS_API_KEY or ~/.config/vos/credentials — mint at vos.so/app/api; - keys can never publish (visibility stays private; humans publish on vos.so) - -Take pipeline — screen recordings in, polished product video out -(ships separately: npm i -D @vosso/cli) - vos create --actions actions.json [out.webm] [--strict] (record + plan + render, one shot) - vos record --actions actions.json [--out take] [--strict] - vos plan [--fresh] - vos frames [--at-zooms | --frame --size WxH] - vos open (serve the take into the studio at vos.so) - vos validate - vos render [out] (a take directory is detected by its doc.json) +const HELP_PLUGIN_HINT = ` +Everything else — the take pipeline (screen recordings in, product video out) +and the vos.so platform verbs (fetch/push/pull/login) — ships as a plugin: + npm i -D @vosso/vos-plugin +Installed plugin verbs appear here and run as plain \`vos \`. +` +const HELP_CONVENTIONS = ` Conventions Results go to stdout; logs go to stderr. --json switches stdout to NDJSON events ending with {"event":"done",…}. Exit codes: 0 ok, 1 error, 2 usage, @@ -85,11 +53,11 @@ function outName(source: string, ext: string): string { async function cmdRender(argv: string[]): Promise { // Polymorphic render: a take DIRECTORY (detected by its doc.json — the take - // pipeline's editable document) renders through the take pipeline; anything - // else is an engine config render. A deterministic sniff, never a flag. + // pipeline's editable document) renders through the plugin; anything else + // is an engine config render. A deterministic sniff, never a flag. const first = argv.find((a) => !a.startsWith('-')) if (first && existsSync(join(first, 'doc.json'))) { - return delegateTake(['render', ...argv]) + return delegate(['render', ...argv]) } const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS) const source = positionals[0] @@ -240,22 +208,33 @@ function packageVersion(name: string): string | null { return null } -async function cmdVersions(argv: string[]): Promise { - const { flags } = parseArgs(argv, BOOLEAN_FLAGS) - const r = createReporter(flags.json === true) - const versions: Record = {} - // Own version: read relative to dist (self-require is blocked by `exports`). +function ownVersion(): string { try { const own = JSON.parse( readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), ) as { version: string } - versions['@vosjs/cli'] = own.version + return own.version } catch { - versions['@vosjs/cli'] = '(unknown)' + return '(unknown)' } +} + +async function cmdVersions(argv: string[]): Promise { + const { flags } = parseArgs(argv, BOOLEAN_FLAGS) + const r = createReporter(flags.json === true) + const versions: Record = {} + versions['@vosjs/cli'] = ownVersion() for (const name of ['@vosjs/core', '@vosjs/elements', '@vosjs/tween', 'playwright']) { versions[name] = packageVersion(name) ?? '(not found)' } + // The plugin, under whichever of its names is installed (the doctor's row). + for (const name of PLUGIN_PACKAGES) { + const v = packageVersion(name) + if (v) { + versions[name] = v + break + } + } if (r.json) r.done({ versions }, '') else for (const [k, v] of Object.entries(versions)) process.stdout.write(`${k} ${v}\n`) return EXIT_OK @@ -284,45 +263,6 @@ async function cmdPreview(argv: string[]): Promise { return EXIT_OK } -async function cmdFetch(argv: string[]): Promise { - const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS) - const source = positionals[0] - if (!source) throw new UsageError('vos fetch [--out dir]') - const r = createReporter(flags.json === true) - const id = parseVosId(source) - // Attached when present so your own private programs fetch too; public and - // unlisted programs need no credential at all. - const key = resolveCredential() - - const meta = await apiJson(`/api/vos/${id}`, { key }) - if (meta.status !== 200) throw new Error(apiError(`fetch vos ${id}`, meta)) - const cfg = await apiJson(`/api/vos/${id}/config`, { key }) - if (cfg.status !== 200) throw new Error(apiError(`fetch config for ${id}`, cfg)) - - const vosMeta = (meta.body.vos ?? {}) as Record - const slug = typeof vosMeta.slug === 'string' && vosMeta.slug ? vosMeta.slug : id - const out = (flags.out as string) ?? slug - await mkdir(out, { recursive: true }) - // The config is written EXACTLY as stored (params/presets included) — this - // file round-trips back through `vos push`. - await writeFile(join(out, 'config.json'), JSON.stringify(cfg.body.config, null, 2)) - await writeFile(join(out, 'meta.json'), JSON.stringify(vosMeta, null, 2)) - - const title = typeof vosMeta.title === 'string' ? vosMeta.title : '' - r.done( - { - out, - id, - slug, - title, - currentVersionId: vosMeta.currentVersionId ?? null, - }, - `Wrote ${out}/config.json + meta.json (${title || id})\n` + - `Edit config.json, then: vos check ${out}/config.json && vos push ${out}/config.json`, - ) - return EXIT_OK -} - async function cmdCheck(argv: string[]): Promise { const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS) const source = positionals[0] @@ -366,346 +306,104 @@ async function cmdCheck(argv: string[]): Promise { return result.ok ? EXIT_OK : EXIT_ERROR } -async function cmdPush(argv: string[]): Promise { - // Polymorphic like `render`: a take DIRECTORY (doc.json) pushes through - // the take pipeline in @vosso/cli — recording upload + doc persistence. - const firstArg = argv.find((a) => !a.startsWith('-')) - if (firstArg && existsSync(join(firstArg, 'doc.json'))) { - return delegateTake(['push', ...argv]) - } - const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS) - const source = positionals[0] - if (!source) { - throw new UsageError( - 'vos push [--vos id] [--title t] [--slug s] [--remix-of id] [--note n] [--label l] [--base versionId] [--overrides id,id]', - ) - } - const r = createReporter(flags.json === true) - const dir = dirname(source) +// --------------------------------------------------------------------------- +// The extension seam: every verb this CLI does not own delegates to the vos +// plugin (the vosso platform layer — take pipeline + vos.so verbs). The +// contract is the plugin's `run(argv)` export; its optional `manifest` +// (verb list + supported host range) feeds `vos help` and the doctor, so a +// new plugin verb never needs a release of this package. +// --------------------------------------------------------------------------- - const parsed = JSON.parse(await readFile(source, 'utf8')) as unknown - const check = runCheck(parsed) - if (!check.ok || !check.config) { - for (const i of check.issues) { - if (i.level === 'error') r.log(`error [${i.source}] ${i.message}`) - } - throw new Error(`config does not validate — run: vos check ${source}`) - } - const config = check.config +const PLUGIN_PACKAGES = ['@vosso/vos-plugin', '@vosso/cli', '@vosso/voila-cli'] - const key = resolveCredential() - if (!key) { - throw new Error( - 'no credential found — set VOS_API_KEY or write the key as the first line of ' + - '~/.config/vos/credentials (mint one at https://vos.so/app/api; a vos_rg_ remix grant works too)', - ) - } +interface PluginManifest { + name?: string + hostRange?: string + verbs?: { name: string; summary: string }[] +} - if (flags.vos) { - // Iterate an existing vos: add a version. --base names the version this - // edit was made FROM (defaulting to the tracked base in meta.json, so a - // fetch→edit→push loop gets stale detection for free), --note/--label - // say what changed, and --overrides consents to touching protected - // (human-edited) nodes — ONLY when the user asked for that change. - const vosId = parseVosId(String(flags.vos)) - const meta = readMeta(dir) - const trackedBase = - meta && meta.id === vosId && typeof meta.currentVersionId === 'string' - ? meta.currentVersionId - : undefined - const body: Record = { config } - const base = flags.base ? String(flags.base) : trackedBase - if (base) body.baseVersionId = base - if (flags.note) body.note = String(flags.note) - if (flags.label) body.label = String(flags.label) - if (flags.overrides) { - body.overrides = String(flags.overrides) - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - } - const res = await apiJson(`/api/vos/${vosId}/versions`, { method: 'POST', key, body }) - if (res.status === 409) { - // The correction path is the data path: both 409 shapes carry what to - // read. stale_base embeds the changes made on the platform since your - // base; protected_conflict lists the human-touched nodes you'd clobber. - const changes = Array.isArray(res.body.changes) - ? (res.body.changes as VersionChange[]) - : [] - for (const line of formatChanges(changes)) r.log(`platform: ${line}`) - const protectedIds = Array.isArray(res.body.protected) ? res.body.protected : [] - const nodes = Array.isArray(res.body.nodes) ? res.body.nodes : [] - r.event({ - event: 'conflict', - reason: res.body.error ?? 'conflict', - changes, - protected: protectedIds, - nodes, - }) - if (res.body.error === 'protected_conflict') { - throw new Error( - `push touches human-edited nodes: ${nodes.join(', ')} — keep the human's values, ` + - `or re-push with --overrides ${nodes.join(',')} ONLY if the user asked for this change`, - ) - } - throw new Error( - `version base is stale — the platform copy changed (${changes.length} edit${changes.length === 1 ? 's' : ''} above). ` + - `Run: vos pull ${dir} — then re-apply your edit and push again`, - ) - } - if (res.status !== 201) throw new Error(apiError(`push version to ${vosId}`, res)) - const version = (res.body.version ?? {}) as Record - // Track what we just made: the new version is the next push's base. - if (typeof version.id === 'string') { - writeMeta(dir, { id: vosId, currentVersionId: version.id }) - } - const watchUrl = `${PLATFORM_ORIGIN}/vos/${vosId}` - const studioUrl = `${PLATFORM_ORIGIN}/studio?vos=${vosId}` - r.done( - { - id: vosId, - versionId: version.id ?? null, - versionNumber: version.versionNumber ?? null, - base: base ?? null, - watchUrl, - studioUrl, - }, - `Pushed version ${String(version.versionNumber ?? '?')} of ${vosId}\n` + - ` watch: ${watchUrl}\n studio: ${studioUrl}`, - ) - return EXIT_OK - } +interface PluginModule { + run?: (argv: string[]) => Promise + manifest?: PluginManifest +} - // Create a new PRIVATE vos. Lineage comes from meta.json (written by - // `vos fetch` beside the config) or --remix-of; the platform validates it. - let meta: Record = {} - try { - meta = JSON.parse(await readFile(join(dirname(source), 'meta.json'), 'utf8')) as Record< - string, - unknown - > - } catch { - // no meta.json — fine, push without lineage - } - const remixOfId = flags['remix-of'] - ? String(flags['remix-of']) - : typeof meta.id === 'string' - ? meta.id - : undefined - const fallbackTitle = - typeof meta.title === 'string' && meta.title - ? `${meta.title} remix` - : basename(source).replace(/\.json$/i, '') || 'vos remix' - const title = ((flags.title as string) ?? fallbackTitle).slice(0, 100) - const slugGiven = typeof flags.slug === 'string' - const baseSlug = slugGiven ? (flags.slug as string) : deriveSlug(title) - - for (let attempt = 0; ; attempt++) { - const slug = attempt === 0 ? baseSlug : `${baseSlug}-${attempt + 1}`.slice(0, 50) - const body: Record = { - title, - slug, - visibility: 'private', - config, - } - if (remixOfId) body.remixOfId = remixOfId - const res = await apiJson('/api/vos', { method: 'POST', key, body }) - if (res.status === 409 && !slugGiven && attempt < 3) { - r.log(`slug "${slug}" is taken — retrying`) +async function loadPlugin(): Promise { + for (const name of PLUGIN_PACKAGES) { + let mod: PluginModule + try { + mod = (await import(name as string)) as PluginModule + } catch { continue } - if (res.status !== 201) throw new Error(apiError('push vos', res)) - const created = (res.body.vos ?? {}) as Record - const id = String(created.id ?? '') - // The directory now TRACKS the created vos (its source stays as - // remixOfId) — the next push/pull needs no flags. - writeMeta(dir, { - id, - currentVersionId: created.currentVersionId ?? null, - title, - slug: created.slug ?? slug, - ...(remixOfId ? { remixOfId } : {}), - }) - const watchUrl = `${PLATFORM_ORIGIN}/vos/${id}` - const studioUrl = `${PLATFORM_ORIGIN}/studio?vos=${id}` - r.done( - { - id, - slug: created.slug ?? slug, - title, - visibility: created.visibility ?? 'private', - remixOfId: remixOfId ?? null, - currentVersionId: created.currentVersionId ?? null, - watchUrl, - studioUrl, - }, - `Created private vos ${id} (${title})\n` + - ` watch: ${watchUrl}\n studio: ${studioUrl}\n` + - `Iterate with: vos push ${source} --vos ${id}`, - ) - return EXIT_OK + if (typeof mod.run === 'function') return mod } + return null } -async function cmdPull(argv: string[]): Promise { - // Take directories pull through @vosso/cli (doc.json comes back into the - // take); a tracked config directory pulls the program path below. - const firstPos = argv.find((a) => !a.startsWith('-')) - if ( - (firstPos && existsSync(join(firstPos, 'doc.json'))) || - (!firstPos && existsSync('doc.json')) - ) { - return delegateTake(['pull', ...argv]) +function versionAtLeast(version: string, min: string): boolean { + const pa = version.split('.').map((n) => Number.parseInt(n, 10)) + const pb = min.split('.').map((n) => Number.parseInt(n, 10)) + for (let i = 0; i < 3; i++) { + const a = pa[i] ?? 0 + const b = pb[i] ?? 0 + if (a !== b) return a > b } - const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS) - // Positional: the tracked directory or its config.json (default cwd). - const target = positionals[0] ?? '.' - const dir = target.endsWith('.json') ? dirname(target) : target - const r = createReporter(flags.json === true) - - const meta = readMeta(dir) - const vosId = flags.vos - ? parseVosId(String(flags.vos)) - : typeof meta?.id === 'string' - ? (meta.id as string) - : null - if (!vosId) { - throw new UsageError( - `no tracked vos in ${dir}/meta.json — pass --vos , or fetch/push first`, - ) - } - const since = flags.since - ? String(flags.since) - : typeof meta?.currentVersionId === 'string' - ? (meta.currentVersionId as string) - : null - if (!since) { - throw new UsageError( - `no base version in ${dir}/meta.json — pass --since `, - ) - } - // The changelog walk is owner-only (edits on private work). - const key = resolveCredential() - if (!key) { - throw new Error( - 'no credential found — set VOS_API_KEY or write the key as the first line of ' + - '~/.config/vos/credentials (mint one at https://vos.so/app/api)', - ) - } - - const res = await apiJson(`/api/vos/${vosId}/changes?since=${encodeURIComponent(since)}`, { - key, - }) - if (res.status !== 200) throw new Error(apiError(`pull changes for ${vosId}`, res)) - - const head = (res.body.head ?? {}) as Record - const changes = Array.isArray(res.body.changes) - ? (res.body.changes as VersionChange[]) - : [] - const protectedIds = Array.isArray(res.body.protected) - ? (res.body.protected as string[]) - : [] + return true +} - if (changes.length === 0) { - r.done( - { id: vosId, upToDate: true, head: head.id ?? since }, - `${vosId}: up to date (base ${since.slice(0, 8)}… is the head)`, +async function delegate(argv: string[], viaAlias = false): Promise { + const plugin = await loadPlugin() + if (!plugin?.run) { + process.stderr.write( + `vos ${argv[0] ?? ''}: not an engine verb.\n` + + 'The take pipeline and the vos.so platform verbs (create, record, plan,\n' + + 'frames, open, validate, fetch, push, pull, login) ship as a plugin:\n' + + ' npm i -D @vosso/vos-plugin\n' + + `then re-run: vos ${argv.join(' ')}\n` + + '(If this was a typo, run: vos help)\n', ) - return EXIT_OK + return EXIT_ERROR } - - for (const line of formatChanges(changes)) r.log(line) - if (protectedIds.length) { - r.log( - `protected (human-edited — keep their values unless asked): ${protectedIds.join(', ')}`, + const range = plugin.manifest?.hostRange + const min = range ? /^>=\s*(\d+\.\d+\.\d+)/.exec(range)?.[1] : undefined + if (min && !versionAtLeast(ownVersion(), min)) { + process.stderr.write( + `note: the installed plugin expects @vosjs/cli ${range} (this is ${ownVersion()}) — upgrade with: npm i -D @vosjs/cli@latest\n`, ) } - if (res.body.truncated === true) { - r.log('walk truncated — more versions exist; pull again after syncing') - } - - if (flags.check === true) { - r.done( - { - id: vosId, - upToDate: false, - versions: changes.length, - head: head.id ?? null, - protected: protectedIds, - changes, - }, - `${changes.length} version${changes.length === 1 ? '' : 's'} behind — run without --check to sync`, - ) - return EXIT_OK + if (viaAlias && argv[0]) { + process.stderr.write(`note: "vos voila ${argv[0]}" is now "vos ${argv[0]}".\n`) } - - // Sync: the head config replaces config.json (the old file is kept as - // config.backup.json), and meta repoints so the next push has the fresh - // base. Your uncommitted local edits live in the backup — re-apply on top. - const cfg = await apiJson(`/api/vos/${vosId}/config`, { key }) - if (cfg.status !== 200) throw new Error(apiError(`fetch head config for ${vosId}`, cfg)) - const configPath = join(dir, 'config.json') - let backedUp = false - if (existsSync(configPath)) { - await writeFile(join(dir, 'config.backup.json'), await readFile(configPath)) - backedUp = true - } - await writeFile(configPath, JSON.stringify(cfg.body.config, null, 2)) - writeMeta(dir, { id: vosId, currentVersionId: head.id ?? since }) - - r.done( - { - id: vosId, - upToDate: false, - versions: changes.length, - head: head.id ?? null, - protected: protectedIds, - changes, - out: configPath, - backup: backedUp ? join(dir, 'config.backup.json') : null, - }, - `Pulled ${changes.length} version${changes.length === 1 ? '' : 's'} → ${configPath}` + - (backedUp ? ` (previous copy: config.backup.json)` : '') + - `\nRe-apply your edit on the new head, then: vos push ${configPath} --vos ${vosId}`, - ) - return EXIT_OK + return await plugin.run(argv) } -// The take pipeline's verbs live in @vosso/cli (published; previously -// @vosso/voila-cli, kept as an install fallback during the transition). -// The delegation contract is its `run(argv)` export. -const TAKE_VERBS = new Set(['create', 'record', 'plan', 'frames', 'open', 'validate']) - -async function delegateTake(argv: string[], viaAlias = false): Promise { - for (const name of ['@vosso/cli', '@vosso/voila-cli']) { - let mod: { run?: (argv: string[]) => Promise } - try { - mod = (await import(name as string)) as typeof mod - } catch { - continue - } - if (typeof mod.run !== 'function') continue - if (viaAlias && argv[0]) { - process.stderr.write(`note: "vos voila ${argv[0]}" is now "vos ${argv[0]}".\n`) +async function printHelp(): Promise { + process.stdout.write(HELP_ENGINE) + const plugin = await loadPlugin() + const verbs = plugin?.manifest?.verbs + if (verbs?.length) { + const label = plugin?.manifest?.name ?? 'vos plugin' + process.stdout.write(`\nPlatform + take pipeline (via ${label})\n`) + for (const v of verbs) { + if (v.name === 'render') continue // polymorphic — already listed above + process.stdout.write(` vos ${v.name.padEnd(8)} ${v.summary}\n`) } - return await mod.run(argv) + } else { + process.stdout.write(HELP_PLUGIN_HINT) } - process.stderr.write( - 'The take pipeline (screen recordings in, product video out) ships separately.\n' + - ' npm i -D @vosso/cli\n' + - `then re-run: vos ${argv.join(' ')}\n`, - ) - return EXIT_ERROR + process.stdout.write(HELP_CONVENTIONS) } +const ENGINE_VERBS = new Set(['render', 'still', 'info', 'versions', 'preview', 'check']) + async function main(): Promise { const [cmd, ...rest] = process.argv.slice(2) if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { - process.stdout.write(HELP) + await printHelp() return cmd ? EXIT_OK : EXIT_USAGE } if (cmd === '--version') return cmdVersions(['--json']) - if (TAKE_VERBS.has(cmd)) return delegateTake([cmd, ...rest]) + if (!ENGINE_VERBS.has(cmd) && cmd !== 'voila') return delegate([cmd, ...rest]) switch (cmd) { case 'render': return cmdRender(rest) @@ -717,18 +415,12 @@ async function main(): Promise { return cmdVersions(rest) case 'preview': return cmdPreview(rest) - case 'fetch': - return cmdFetch(rest) case 'check': return cmdCheck(rest) - case 'push': - return cmdPush(rest) - case 'pull': - return cmdPull(rest) // Hidden alias for existing scripts; not in HELP. Same code path as the // promoted verbs, plus a one-line pointer at the new spelling. case 'voila': - return delegateTake(rest, true) + return delegate(rest, true) default: throw new UsageError(`unknown command "${cmd}" — run vos help`) } diff --git a/packages/cli/src/platform.ts b/packages/cli/src/platform.ts deleted file mode 100644 index 0f07ad6..0000000 --- a/packages/cli/src/platform.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * vos.so platform client — the pieces of fetch/push that talk HTTP. - * - * Contract notes (mirrored at https://vos.so/llms-remix.txt): - * - public/unlisted programs read with no auth; writes ride a bearer key - * - credentials resolve env → ~/.config/vos/credentials, and are NEVER - * printed — not in logs, not in errors, not in NDJSON events - * - pushed configs travel as the RAW object (params/presets preserved); - * the platform validates and compiles server-side - */ -import { readFileSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { UsageError } from './args' - -export const PLATFORM_ORIGIN = process.env.VOS_ORIGIN ?? 'https://vos.so' - -/** - * Accepts a bare vos id, a watch URL (vos.so/vos/{id}), an embed URL - * (/embed/vos/{id}), or a studio/stage URL (?vos={id}). - */ -export function parseVosId(input: string): string { - if (!/^https?:\/\//.test(input)) { - if (/^[A-Za-z0-9_-]+$/.test(input)) return input - throw new UsageError(`"${input}" is not a vos id or URL`) - } - let url: URL - try { - url = new URL(input) - } catch { - throw new UsageError(`"${input}" is not a valid URL`) - } - const query = url.searchParams.get('vos') - if (query) return query - const path = url.pathname.match(/\/vos\/([A-Za-z0-9_-]+)/) - if (path) return path[1] - throw new UsageError(`could not find a vos id in ${input} — expected /vos/{id} or ?vos={id}`) -} - -/** - * Resolution order (llms-remix.txt): VOS_API_KEY env, then the first line of - * ~/.config/vos/credentials. Returns null when neither exists — callers - * decide whether the operation needs auth. - */ -export function resolveCredential(): string | null { - const env = process.env.VOS_API_KEY?.trim() - if (env) return env - try { - const first = readFileSync(join(homedir(), '.config', 'vos', 'credentials'), 'utf8') - .split('\n')[0] - .trim() - return first || null - } catch { - return null - } -} - -export function deriveSlug(title: string): string { - return ( - title - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 50) - .replace(/-+$/, '') || 'remix' - ) -} - -export interface ApiResult { - status: number - body: Record -} - -export async function apiJson( - path: string, - init: { method?: string; key?: string | null; body?: unknown } = {}, -): Promise { - const headers: Record = { accept: 'application/json' } - if (init.key) headers.authorization = `Bearer ${init.key}` - if (init.body !== undefined) headers['content-type'] = 'application/json' - const res = await fetch(`${PLATFORM_ORIGIN}${path}`, { - method: init.method ?? 'GET', - headers, - body: init.body === undefined ? undefined : JSON.stringify(init.body), - }) - let body: Record = {} - try { - body = (await res.json()) as Record - } catch { - // non-JSON error bodies stay {} - } - return { status: res.status, body } -} - -/** Human-readable error line for a failed platform response. Never echoes credentials. */ -export function apiError(what: string, r: ApiResult): string { - const detail = - typeof r.body.error === 'string' - ? r.body.error - : r.body.details !== undefined - ? JSON.stringify(r.body.details) - : '' - const hint = - r.status === 401 - ? ' (set VOS_API_KEY or write ~/.config/vos/credentials — mint a key at https://vos.so/app/api)' - : '' - return `${what} → ${r.status}${detail ? `: ${detail}` : ''}${hint}` -} - -// --------------------------------------------------------------------------- -// Local tracking state (meta.json) + the pull-contract payload -// --------------------------------------------------------------------------- - -/** - * meta.json beside a config.json makes the directory TRACK a vos, like a - * remote: `id` + `currentVersionId` are the pull/push base. `vos fetch` - * seeds it from the source; `vos push` repoints it at what it created. - */ -export function readMeta(dir: string): Record | null { - try { - return JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8')) as Record< - string, - unknown - > - } catch { - return null - } -} - -export function writeMeta(dir: string, patch: Record): void { - const merged = { ...(readMeta(dir) ?? {}), ...patch } - writeFileSync(join(dir, 'meta.json'), JSON.stringify(merged, null, 2)) -} - -/** One version of the /changes walk (shape mirrored from the platform). */ -export interface VersionChange { - versionId?: string - versionNumber?: number - origin?: string - label?: string | null - note?: string | null - summary?: string -} - -/** Human lines for a changes walk — origin/label attributed, note indented. */ -export function formatChanges(changes: readonly VersionChange[]): string[] { - const lines: string[] = [] - for (const ch of changes) { - const label = ch.label ? ` · ${ch.label}` : '' - lines.push( - `v${String(ch.versionNumber ?? '?')} (${ch.origin ?? 'unknown'}${label}): ${ch.summary ?? ''}`, - ) - if (ch.note) lines.push(` note: ${ch.note}`) - } - return lines -}