#!/usr/bin/env node /** * create-trek-plugin [--type integration|page|widget|trip-page] (#plugins, M6). * Scaffolds a working plugin: manifest, an isolated server entry using * definePlugin, a README you must fill in, and (page/widget) a starter iframe. */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { intro, outro, note, logInfo, logSuccess, logWarn, spinner, promptText, promptSelect, promptMultiselect, promptGroupMultiselect, promptConfirm, PERMISSION_FAMILIES, } from './ui.js'; import { KNOWN_ADDONS } from '../manifest.js'; import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js'; import { notifySdkUpdate } from './update-notice.js'; /** This package's own version, for the scaffold's devDependency range. */ function sdkVersionRange(): string { try { const pkg = createRequire(import.meta.url)('../../package.json') as { version?: string }; return pkg.version ? `^${pkg.version}` : '^1'; } catch { return '^1'; } } export interface ScaffoldOptions { author?: string; description?: string; permissions?: string[]; /** * External hosts the plugin may call — required by the manifest when `http:outbound` is * granted, UNLESS the plugin is an operatorEgress one (below), whose hosts an admin adds * after install because the author cannot know them (a self-hosted Gotify, an ntfy). */ egress?: string[]; /** The admin supplies the hosts post-install. Defaults on for a channel, and whenever `http:outbound` is wanted with no `egress`. */ operatorEgress?: boolean; /** Addon ids that must be enabled for this plugin to activate. */ requiredAddons?: string[]; /** Other plugins this one depends on, each pinned by a semver range. */ pluginDependencies?: Array<{ id: string; version: string }>; /** * Which starter to generate. This is NOT the manifest `type` — a notification * channel is a plain `integration` that implements the `notificationChannel` hook. */ template?: 'blank' | 'notification-channel'; /** A lucide icon name (PascalCase). Falls back to a sensible default for the type. */ icon?: string; /** Where a `widget` renders. Omitting it silently means 'sidebar'. */ slot?: WidgetSlot; } export const TEMPLATES = ['blank', 'notification-channel'] as const; /** * The credential fields the OAuth broker (server-side `oauth.getToken`) needs to run the * authorization-code flow on the plugin's behalf. ALL `scope: 'instance'` — unlike the * notification-channel template's `scope: 'user'` settings above, these are configured ONCE * per install by an admin, not per recipient: the broker itself reads them at token-exchange * time, there is no per-user client id/secret. Deliberately not user-scoped. */ const OAUTH_SETTINGS: Array<{ key: string; label: string; input_type: string; required: boolean; secret?: boolean; scope: 'instance'; }> = [ { key: 'oauth_authorize_url', label: 'OAuth authorize URL', input_type: 'text', required: true, scope: 'instance' }, { key: 'oauth_token_url', label: 'OAuth token URL', input_type: 'text', required: true, scope: 'instance' }, { key: 'oauth_scopes', label: 'OAuth scopes (space-separated)', input_type: 'text', required: false, scope: 'instance' }, { key: 'oauth_client_id', label: 'OAuth client id', input_type: 'text', required: true, scope: 'instance' }, { key: 'oauth_client_secret', label: 'OAuth client secret', input_type: 'text', required: true, secret: true, scope: 'instance' }, ]; /** Mirrors the server's widget slots (and the manifest validator's). */ export const WIDGET_SLOTS = ['sidebar', 'hero', 'place-detail', 'day-detail', 'reservation-detail'] as const; export type WidgetSlot = (typeof WIDGET_SLOTS)[number]; export const WIDGET_SLOT_HINTS: Record = { sidebar: 'The trip sidebar, alongside weather and countdown (the default)', hero: 'The banner at the top of the trip', 'place-detail': "Inside a place's detail panel", 'day-detail': "Inside a day's detail panel", 'reservation-detail': "Inside a reservation's detail panel", }; /** * A defensible starting icon per plugin type. Not a guess at what the plugin DOES — it cannot know * that — just a better default than every new plugin sharing the generic `Blocks` glyph. */ function defaultIconFor(type: string, isChannel: boolean): string { if (isChannel) return 'BellRing'; if (type === 'widget') return 'LayoutGrid'; if (type === 'page') return 'BookOpen'; if (type === 'trip-page') return 'Map'; return 'Plug'; } export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): void { if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`); if (!['integration', 'page', 'widget', 'trip-page'].includes(type)) throw new Error(`invalid type "${type}"`); const template = opts.template ?? 'blank'; if (!TEMPLATES.includes(template)) throw new Error(`invalid template "${template}"`); const isChannel = template === 'notification-channel'; // A notification channel is server-only by construction: it takes a rendered // message and pushes it somewhere. Nothing else makes sense. if (isChannel && type !== 'integration') throw new Error('the notification-channel template requires type "integration"'); const root = path.join(targetDir, name); if (fs.existsSync(root)) throw new Error(`${root} already exists`); fs.mkdirSync(path.join(root, 'server'), { recursive: true }); const displayName = name.replaceAll('-', ' ').replaceAll(/\b\w/g, (c) => c.toUpperCase()); let perms = opts.permissions?.length ? opts.permissions : ['db:own']; const egress = opts.egress ?? []; if (isChannel) { // The grants a channel cannot work without. With known hosts it gets a per-host grant // each; with none — the usual case for a self-hosted target — it gets the bare // `http:outbound` and relies on operatorEgress below, rather than a fake placeholder // host that would otherwise ship in the published manifest. const outbound = egress.length ? egress.map((h) => `http:outbound:${h}`) : ['http:outbound']; const required = ['hook:notification-channel', ...outbound]; perms = [...new Set([...perms.filter((p) => p !== 'db:own'), ...required])]; } // A plugin that wants outbound but names no host is only valid as an operatorEgress // plugin — the admin supplies the hosts after install. const wantsOutbound = perms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')); const operatorEgress = opts.operatorEgress ?? (isChannel || (wantsOutbound && egress.length === 0)); const manifest: Record = { id: name, name: displayName, version: '1.0.0', apiVersion: 1, author: opts.author || 'Your Name', description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'), type, // Floor 4.0.0: the host surface this scaffold is written against — the enforced // apiVersion gate, the generated core-event catalog (EVENT_FAMILIES / // EVENT_SNAPSHOT_GRANT), the OAuth-broker settings contract — is the TREK 4 surface. // Claiming an older floor lets the plugin install on a host where parts of it are // missing, which fails at the first call instead of a clear "incompatible" at install. // Ceiling 5.0.0: bounded, per the trek-range rule; widen only against a tested host. trek: '>=4.0.0 <5.0.0', nativeModules: false, permissions: perms, // Dependency declarations (empty by default). `requiredAddons` lists addon ids // that must be enabled to activate; `pluginDependencies` lists other plugins // ({ id, version-range }) that must be installed + satisfied first. requiredAddons: opts.requiredAddons ?? [], pluginDependencies: opts.pluginDependencies ?? [], }; // The store tile, the nav entry, the widget header and the admin row all render this. Omitting // it is legal — TREK falls back to `Blocks` — but then every plugin that skipped it is the same // grey square in the store, so the scaffold picks a real one rather than leaving it out. manifest.icon = opts.icon && LUCIDE_ICON_NAMES.has(opts.icon) ? opts.icon : defaultIconFor(type, isChannel); if (egress.length) manifest.egress = egress; // A notification channel usually targets a SELF-HOSTED service, whose hostname the // author cannot know at publish time. Declaring operatorEgress lets the admin add the // real host after install; without it the plugin only ever reaches the hosts above. if (operatorEgress) manifest.operatorEgress = true; // A settings-page button so the user can verify their credentials without waiting for // a real notification. Actions are USER-INITIATED, so ctx.settings.get() works inside. if (isChannel) manifest.actions = [{ key: 'testConnection', label: 'Test connection' }]; // Routes are declared in server/index.js (definePlugin), NOT the manifest — the // host ignores a manifest `routes`. A `page` plugin gets its nav entry // automatically, so there's no `capabilities.nav` either; only `widget` carries a // capability worth scaffolding. // `slot` decides WHERE the widget renders, and omitting it silently means 'sidebar' — so a // widget meant for a place's detail panel would quietly appear in the trip sidebar instead, // with nothing anywhere to say why. Always write it. if (type === 'widget') { manifest.capabilities = { widget: { title: displayName, defaultSize: 'medium', slot: opts.slot ?? 'sidebar' } }; } if (isChannel) { manifest.capabilities = { notificationChannel: { title: displayName } }; // The recipient's own credential. `scope: 'user'` is what makes it per-user, and // the host hands the decrypted value to your hook as `config` at send time. manifest.settings = [ { key: 'serverUrl', label: 'Server URL', input_type: 'text', placeholder: 'https://gotify.example.com', hint: egress.length ? 'Your Gotify server. Must match an entry in the manifest `egress` list.' : 'Your Gotify server. Its host must be allowed by an admin (Admin → Plugins → Allowed hosts).', required: true, scope: 'user', }, { key: 'appToken', label: 'App token', input_type: 'text', hint: 'Create an application in Gotify and paste its token here.', required: true, secret: true, scope: 'user', }, ]; } // The broker (ctx.oauth.getToken) needs somewhere to read the authorize/token URLs and the // app's client id/secret — an admin configures them once, instance-wide, and the broker // reads them at token-exchange time. Merge with any settings the template above already // wrote (a channel template never grants oauth:client today, so there is no real overlap, // but keys are deduped anyway rather than trusting that stays true). if (perms.includes('oauth:client')) { const existing = (manifest.settings as Array<{ key: string }> | undefined) ?? []; const seen = new Set(existing.map((s) => s.key)); manifest.settings = [...existing, ...OAUTH_SETTINGS.filter((s) => !seen.has(s.key))]; } fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n'); fs.writeFileSync(path.join(root, 'server', 'index.js'), isChannel ? CHANNEL_JS : SERVER_JS(perms.includes('db:own'))); fs.writeFileSync(path.join(root, 'README.md'), readme(name, opts.description ?? (isChannel ? `> Deliver your TREK notifications to ${displayName}.` : '> One sentence: what this plugin does.'), perms)); // `type: commonjs` pins how the entry is parsed everywhere (dev, tests, TREK); // the SDK is a devDependency ONLY (types + mock host) — at runtime both the // dev server and TREK inject it, so it is never vendored into the artifact. fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name, version: '1.0.0', private: true, type: 'commonjs', scripts: { dev: 'npx -y trek-plugin-sdk dev', pack: 'npx -y trek-plugin-sdk pack' }, devDependencies: { 'trek-plugin-sdk': sdkVersionRange() }, }, null, 2) + '\n'); if (type !== 'integration') { fs.mkdirSync(path.join(root, 'client'), { recursive: true }); fs.writeFileSync(path.join(root, 'client', 'index.html'), CLIENT_HTML); } // The README has always linked ./docs/screenshot.png, and the scaffold has never created the // directory — so the very first thing an author was told to do had nowhere to go, and the only // thing that noticed was the registry, after the release was immutable. Make the destination // real, and say what belongs in it. fs.mkdirSync(path.join(root, 'docs'), { recursive: true }); fs.writeFileSync(path.join(root, 'docs', 'README.md'), DOCS_README); // Normalise line endings. The registry pins the artifact's sha256, and a CRLF checkout produces // different bytes for the same source — so a Windows author re-packing the same commit gets a // hash that no longer matches the entry they published. fs.writeFileSync(path.join(root, '.gitattributes'), GITATTRIBUTES); } const DOCS_README = `Put \`screenshot.png\` here. It is what the plugin's store card shows, and the registry REJECTS a plugin whose README has no screenshot that resolves to a real image — so this is not optional. - 16:9, 1600×900 is ideal. The card crops the edges, so leave some margin. - Show the plugin doing its job, in context, inside TREK. The quickest way: run \`trek-plugin dev\`, then \`trek-plugin shot\` in another terminal — it renders your plugin in a themed TREK frame and writes docs/screenshot.png for you. This directory is NOT shipped in plugin.zip; it lives in your repo, and the README links it at the commit the registry pins. `; const GITATTRIBUTES = `# The registry pins the sha256 of your packed artifact. A CRLF checkout changes those bytes for # the same source, so re-packing the same commit on Windows would produce a hash that no longer # matches the entry you published. Pin the line endings and the artifact stays reproducible. * text=auto eol=lf *.png binary *.jpg binary *.zip binary `; const CHANNEL_JS = `// A TREK notification channel — runs in an isolated child process. // // TREK renders every notification into the recipient's language and hands it to you // ready to send. You never touch i18n, and you never pick a recipient: the host does // that, then calls you once per recipient who has this channel switched on. // // This hook is HOST-initiated, so there is NO acting user while it runs: // - ctx.settings.get() returns undefined here (it resolves against the acting user) // - trip reads are refused // The recipient's own settings — the fields you declared with scope: 'user' in // trek-plugin.json — arrive DECRYPTED as the \`config\` argument. That is the only way // to reach them, and it is why a channel plugin cannot enumerate anyone's trips. const { definePlugin } = require('trek-plugin-sdk'); async function push(config, title, message) { // Every host you call must be listed in the manifest's \`egress\` array AND granted // via http:outbound:, or the child's network guard blocks the request. const res = await fetch(String(config.serverUrl).replace(/\\/+$/, '') + '/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-Gotify-Key': String(config.appToken), }, body: JSON.stringify({ title, message, priority: 5 }), }); // Throw on failure: the host logs it and isolates it, so one dead channel can never // stop the others (or the in-app notification) from being delivered. if (!res.ok) throw new Error('Gotify responded ' + res.status + ' ' + (await res.text().catch(() => ''))); } module.exports = definePlugin({ // A button on your settings page. USER-INITIATED, so unlike the channel hook there IS // an acting user (whoever clicked) — ctx.settings.get() returns THEIR values, which is // what makes "test my credentials" possible. actions: { async testConnection(ctx) { const config = { serverUrl: await ctx.settings.get('serverUrl'), appToken: await ctx.settings.get('appToken'), }; await push(config, 'TREK', 'Test notification. If you can read this, your channel works.'); return { ok: true, message: 'Connected.' }; }, }, hooks: { notificationChannel: { async send(msg, config, ctx) { // msg = { event, title, body, url?, tripName? } — already localized. const body = msg.url ? msg.body + '\\n\\n' + msg.url : msg.body; await push(config, msg.title, body); ctx.log.info('delivered ' + msg.event); }, // Optional — backs the "Send test" button in the user's notification settings. async test(config) { await push(config, 'TREK', 'Test notification. If you can read this, your channel works.'); }, }, }, }); `; const SERVER_JS = (has_db: boolean) => `// Built plugin entry — runs in an isolated child process. const { definePlugin } = require('trek-plugin-sdk'); module.exports = definePlugin({ async onLoad(ctx) { ${has_db ? 'await ctx.db.migrate(\'001_init\', \'CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)\');' : ''} ctx.log.info('plugin loaded'); }, routes: [ { method: 'GET', path: '/hello', auth: true, async handler(req, ctx) { return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ hello: req.user && req.user.username }) }; }, }, ], }); `; // The `` marker is expanded by `dev` and `pack` into the inlined // design kit (native styles + a `window.trek` bridge). The source stays this one // line, so the starter is already themed, glassy and wired on first run. const CLIENT_HTML = ` Plugin
Your plugin

Click below to call your /hello route.

not connected
`; /** One markdown table row per granted scope, with the catalog's description as the "Why". */ function permissionRows(scopes: string[]): string { const rows = (scopes.length ? scopes : ['db:own']).map( (s) => `| \`${s}\` | 'Describe why this plugin needs it.' |`, ); return rows.join('\n'); } function readme(name: string, description: string, scopes: string[]): string { return `# ${name} ${description} ![screenshot](./docs/screenshot.png) ## What it does Describe the feature this plugin adds to TREK. ## Screenshots Show it in context. Commit a \`docs/screenshot.png\` — it's what the store card shows. A 16:9 image (e.g. 1600×900) with your plugin centred and some margin looks best (the card crops the edges). ## Permissions | Permission | Why | |---|---| ${permissionRows(scopes)} ## Setup How to configure it. ## License Your plugin is your own code — license it however you like; TREK does not impose one. Replace this line with your license (for example, MIT). `; } const SLUG = /^[a-z][a-z0-9-]{2,39}$/; /** Resolve a user-typed directory: expand a leading `~`, then make it absolute. */ function resolveDir(input: string): string { const raw = (input || '.').trim(); const expanded = raw === '~' || raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(1)) : raw; return path.resolve(expanded); } /** True when `dir` sits inside an existing git work tree (so we don't offer to nest a repo). */ function insideGitRepo(dir: string): boolean { try { execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, stdio: 'ignore' }); return true; } catch { return false; } } /** * Interactive scaffold: a Clack wizard that collects the details, writes the * plugin, and offers to set up git + install deps. Returns the created plugin id. * Only ever called when stdout is a TTY (the dispatcher guards this). */ export async function interactiveScaffold(defaultDir: string, presetName?: string): Promise { intro('create-trek-plugin'); const id = presetName && SLUG.test(presetName) ? presetName : await promptText({ message: 'Plugin id', placeholder: 'flight-tracker', initialValue: presetName ?? '', validate: (v) => (SLUG.test((v ?? '').trim()) ? undefined : 'lowercase slug, 3–40 chars (e.g. flight-tracker)'), }).then((v) => v.trim()); const location = await promptText({ message: 'Where should the plugin be created?', placeholder: `. (creates ./${id}/ here)`, defaultValue: defaultDir, validate: (v) => (fs.existsSync(path.join(resolveDir(v || defaultDir), id)) ? `${path.join(v || '.', id)} already exists` : undefined), }); const parentDir = resolveDir(location || defaultDir); const dest = path.join(parentDir, id); const type = await promptSelect({ message: 'What kind of plugin is this?', initialValue: 'integration', options: [ { value: 'integration', label: 'integration', hint: 'server-only: routes, hooks, background work' }, { value: 'page', label: 'page', hint: 'adds a full navigation page (sandboxed iframe UI)' }, { value: 'widget', label: 'widget', hint: 'adds a dashboard widget (sandboxed iframe UI)' }, { value: 'trip-page', label: 'trip-page', hint: 'adds a tab inside every trip (sandboxed iframe UI)' }, ], }); // A starter, not a manifest type — a notification channel is a plain `integration` // that implements the notificationChannel hook, so it only makes sense to offer here. let template: 'blank' | 'notification-channel' = 'blank'; if (type === 'integration') { template = await promptSelect<'blank' | 'notification-channel'>({ message: 'Start from a template?', initialValue: 'blank', options: [ { value: 'blank', label: 'blank', hint: 'an empty plugin with one example route' }, { value: 'notification-channel', label: 'notification channel', hint: 'deliver TREK notifications to Gotify/Pushover/…' }, ], }); } const author = await promptText({ message: 'Author', placeholder: 'Your Name', defaultValue: 'Your Name' }); const description = await promptText({ message: 'One-line description', placeholder: 'Describe what your plugin does.', defaultValue: 'Describe what your plugin does.', }); // ONE prompt, grouped by area. 58 permissions in a flat list is unreadable, but asking in // two steps (areas → permissions) was worse: Clack has no "back", so picking the wrong // areas was a dead end you could only escape with ^C. A grouped multiselect is a single // scrollable screen — space toggles a permission, or a whole area from its header. const permissions = await promptGroupMultiselect({ message: 'Which permissions does it need? (space toggles; a group header toggles the whole area)', options: Object.fromEntries( PERMISSION_FAMILIES.map((f) => [ `${f.label} — ${f.hint}`, f.permissions.map((p) => ({ value: p.value, label: p.value, hint: p.hint })), ]), ), initialValues: ['db:own'], required: false, selectableGroups: true, }); let egress: string[] | undefined; // The scaffold turns each host into a matching http:outbound: grant. Leaving it // blank is legitimate — a plugin for a service that is only ever SELF-HOSTED has no host // to name, so it ships as an operatorEgress plugin and the admin adds the real host. if (permissions.includes('http:outbound') || template === 'notification-channel') { const raw = await promptText({ message: template === 'notification-channel' ? 'Which host does your notification service live on? (blank if it is always self-hosted)' : 'External hosts it may call (comma-separated; blank if only the admin can know them)', placeholder: template === 'notification-channel' ? 'gotify.example.com' : 'api.example.com, api.other.com', }); egress = raw.split(',').map((s) => s.trim()).filter(Boolean); if (!egress.length) { logInfo('No hosts named — the plugin will declare operatorEgress, and the TREK admin adds the real hosts after installing it.'); } } const requiredAddons = await promptMultiselect({ message: 'Requires any TREK addons enabled? (optional — the plugin can only activate when these are on)', options: KNOWN_ADDONS.map((a) => ({ value: a, label: a })), initialValues: [], required: false, }); // A widget with no slot silently renders in the sidebar. Ask, rather than let an author // discover months later that `hero` was always available. let slot: WidgetSlot | undefined; if (type === 'widget') { slot = await promptSelect({ message: 'Where should the widget appear?', initialValue: 'sidebar', options: WIDGET_SLOTS.map((s) => ({ value: s, label: s, hint: WIDGET_SLOT_HINTS[s] })), }); } // The store tile, the nav entry and the admin row all render this. The registry REJECTS a name // lucide does not have, so validate it here rather than at publish time. const fallbackIcon = defaultIconFor(type, template === 'notification-channel'); const icon = await promptText({ message: 'Icon — a lucide name (see https://lucide.dev/icons)', placeholder: fallbackIcon, defaultValue: fallbackIcon, validate: (v) => { const name = (v ?? '').trim(); if (!name) return undefined; // the default is used return LUCIDE_ICON_NAMES.has(name) ? undefined : `lucide has no icon called "${name}" — TREK would fall back to Blocks`; }, }).then((v) => v.trim() || fallbackIcon); note( [ `id ${id}`, `type ${type}`, template !== 'blank' ? `template ${template}` : undefined, slot ? `slot ${slot}` : undefined, `icon ${icon}`, `location ${dest}`, `author ${author}`, `permissions ${permissions.join(', ') || '(none)'}`, egress?.length ? `egress ${egress.join(', ')}` : undefined, egress && !egress.length ? 'egress (admin-supplied — operatorEgress)' : undefined, requiredAddons.length ? `addons ${requiredAddons.join(', ')}` : undefined, ].filter(Boolean).join('\n'), 'Review', ); const confirmed = await promptConfirm({ message: `Create the plugin at ${dest}?`, initialValue: true }); if (!confirmed) { outro('Cancelled — nothing was written.'); process.exit(0); } scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons, template, icon, slot }); logSuccess(`Created ${dest}`); if (!insideGitRepo(parentDir)) { const doGit = await promptConfirm({ message: 'Initialize a git repository?', initialValue: true }); if (doGit) { try { execFileSync('git', ['init'], { cwd: dest, stdio: 'ignore' }); logSuccess('Initialized a git repository'); } catch { logWarn('Could not initialize git — run `git init` yourself later.'); } } } const doInstall = await promptConfirm({ message: 'Install dependencies now?', initialValue: true }); if (doInstall) { const s = spinner(); s.start('Installing dependencies'); try { execFileSync('npm', ['install'], { cwd: dest, stdio: 'ignore' }); s.stop('Dependencies installed'); } catch { s.stop('Could not install dependencies'); logWarn('Run `npm install` in the plugin directory later.'); } } outro(nextStepsAfterCreate(dest)); return id; } /** * What to do next, said ONCE. * * There used to be three of these — the wizard's outro, the `create` dispatch branch and the * standalone `create-trek-plugin` bin — and they named three different next commands (`dev`, * `dev`, `validate`). Whichever an author hit was a coin toss, and none of them mentioned that * the plugin they had just been handed could not be published as-is. * * So it points at `dev` (the thing you actually want to do next) and at `status` (the thing that * will tell you the truth about what is still missing), and it does not pretend the scaffold is * finished. */ export function nextStepsAfterCreate(dest: string): string { const cd = path.relative(process.cwd(), dest) || dest; return [ 'Next steps:', ` cd ${cd}`, ' trek-plugin dev run it locally, hot-reloaded', ' trek-plugin status what is left before you can publish', '', 'The scaffold is not publishable yet — it needs a written README and a screenshot.', '`status` lists exactly what, whenever you ask it.', ].join('\n'); } // CLI entry if (process.argv[1] && process.argv[1].endsWith('create.js')) { notifySdkUpdate(); const args = process.argv.slice(2); const name = args.find((a: string) => !a.startsWith('-')); const typeIdx = args.indexOf('--type'); const type = typeIdx >= 0 ? args[typeIdx + 1] : 'integration'; const tplIdx = args.indexOf('--template'); const template = (tplIdx >= 0 ? args[tplIdx + 1] : 'blank') as ScaffoldOptions['template']; if (!name) { console.error('usage: create-trek-plugin [--type integration|page|widget|trip-page] [--template blank|notification-channel]'); process.exit(2); } try { scaffold(name, type, process.cwd(), { template }); // The SAME message the wizard and `trek-plugin create` print. These three used to disagree. console.log(nextStepsAfterCreate(path.join(process.cwd(), name))); } catch (e) { console.error(e instanceof Error ? e.message : e); process.exit(1); } }