mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
d110b3bb6d
* fix(plugins): enforce manifest apiVersion at install and activation * test(plugins): exercise the absent-apiVersion default * feat(plugins): generate the core-event catalog into host-facts * docs(plugins): explain the packing.listBags write-permission binding * test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header * fix(plugin-sdk): validate settings options and oauth like the host * fix(plugin-sdk): model the full manifest shape and stop dropping fields * fix(plugin-sdk): drop non-string routeProfiles icons like the host * docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId * feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host * feat(plugin-sdk): expose the generated core-event catalog * feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0 * feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins * chore(plugin-sdk): fix stale mirror docs and the colliding permissions export * docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing * feat(plugin-sdk): raise the scaffold trek floor to 4.0.0 * docs(wiki): bring the plugin pages in line with the current runtime * docs: bring the workspace CLAUDE.md guides up to date and make them committable * feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates * docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing * chore(plugin-sdk): 1.6.0
683 lines
30 KiB
JavaScript
683 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* create-trek-plugin <name> [--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<WidgetSlot, string> = {
|
||
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.replace(/-/g, ' ').replace(/\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<string, unknown> = {
|
||
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:<host>, 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 `<!-- trek:ui -->` 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 = `<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Plugin</title>
|
||
<!-- trek:ui -->
|
||
</head>
|
||
<body>
|
||
<div class="trek-glass trek-stack" style="margin: 16px">
|
||
<div class="trek-title">Your plugin</div>
|
||
<p class="trek-muted" id="hello">Click below to call your /hello route.</p>
|
||
<div class="trek-cluster">
|
||
<button class="trek-btn trek-btn--primary" id="ping">Say hello</button>
|
||
<span class="trek-chip trek-chip--accent" id="who">not connected</span>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
// The design kit is inlined above (window.trek + native styles). The frame is
|
||
// sandboxed at an opaque origin — reach TREK only through window.trek.
|
||
trek.onContext(function (ctx) {
|
||
document.getElementById('who').textContent = (ctx.user ? ctx.user.name + ' \\u00b7 ' : '') + ctx.theme;
|
||
});
|
||
document.getElementById('ping').addEventListener('click', async function () {
|
||
try {
|
||
var data = await trek.invoke('/hello');
|
||
document.getElementById('hello').textContent = 'Hello, ' + ((data && data.hello) || 'traveller') + '!';
|
||
} catch (e) {
|
||
trek.notify('error', e.message);
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
/** 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}
|
||
|
||

|
||
|
||
## 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<string> {
|
||
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<string>({
|
||
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<string>({
|
||
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:<host> 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<string>({
|
||
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<WidgetSlot>({
|
||
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 <name> [--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);
|
||
}
|
||
}
|