@sparelabs/sightline-extension-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spare Labs Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @sparelabs/sightline-extension-cli
2
+
3
+ `sightline-ext`: scaffold, run, build, test and deploy a **hosted Sightline extension**, a backend (and optionally a UI) that runs in Sightline's sandbox, one instance per workspace, and talks to Sightline only through its published contract.
4
+
5
+ ```bash
6
+ npx @sparelabs/sightline-extension-cli init my-notes
7
+ cd my-notes && npm install
8
+ npm test # build + contract tests against an emulated Sightline
9
+ ```
10
+
11
+ You need Node 20.19+, [Deno](https://deno.com) (the extension runtime) and Docker (your extension's local Postgres).
12
+
13
+ ## Commands
14
+
15
+ | Command | What it does |
16
+ | --- | --- |
17
+ | `init <dir> [--id <kebab-id>] [--name "…"] [--publisher <kebab-id>] [--force]` | Writes a new extension repository: manifest, server, a first migration, `sightline-ext.json`, tsconfig, a CI workflow and a README. It depends on the published packages only. |
18
+ | `build` | Validates `sightline.extension.json` against the published contract (`@sparelabs/sightline-extension-manifest`) and the rules Sightline applies at publish (trust `T2`, a hosted runtime, a kebab-case id, `MAJOR.MINOR.PATCH`), checks `migrations/NNNN_<name>.sql` (numbered from `0001`, no gaps, at most 256 KB each), bundles `server/main.ts` into one ESM file (`runtime.entry`, usually `dist/server.mjs`, at most 5 MB) and lints the bundle with the same `lintBundle` Sightline runs at publish (from `@sparelabs/sightline-extension-manifest`: no remote, package or relative imports, no computed dynamic import). With `ui.frames` in the manifest it also reads your UI build from `dist/ui` (set `"ui": { "dir": … }` in `sightline-ext.json` to move it; build the UI with your own tool first) and checks it with Sightline's publish rules: allowed file types, sizes, no scripts from other origins, no root-absolute URLs (use a relative base, e.g. Vite `base: './'`), the entry HTML present. |
19
+ | `dev [--port <n>] [--core-port 8787] [--no-db]` | Builds, starts the emulated core (`@sparelabs/sightline-extension-devkit`), your Postgres with your migrations, and the bundle under the **same Deno permission flags as production**; rebuilds and restarts on every change. `npx sightline-devkit invoke tool <name> --as user:alice` calls it. |
20
+ | `test [--no-build] [--no-db] [--keep-db]` | Builds, runs the extension against the emulator and its own database, and checks it against the HTTP contract (`@sparelabs/sightline-extension-testing`): health, token refusal, the `ToolResult` envelope, routing. Exit 1 on any failure. |
21
+ | `deploy [--bindings bindings.json] [--no-build]` | Builds, publishes the release to a workspace (identical bytes are a no-op) and installs it. Refuses when another version is installed: use `upgrade`. |
22
+ | `upgrade [--bindings bindings.json] [--no-build]` | Builds, publishes, and moves the installed extension to the manifest's version. A permission the new version adds needs a binding. |
23
+ | `uninstall [<id>] [--purge --yes]` | Uninstalls. Data is kept 30 days; `--purge --yes` drops it now. |
24
+
25
+ Common options: `--dir <project>`, `--manifest <file>`, `--config <file>`, `--entry <file>`.
26
+
27
+ ## sightline-ext.json
28
+
29
+ Local only; never deployed.
30
+
31
+ ```json
32
+ {
33
+ "entry": "server/main.ts",
34
+ "contract": {
35
+ "tool": { "name": "notes_list", "input": { "limit": 5 }, "as": "user:alice" },
36
+ "job": { "name": "weekly_digest" },
37
+ "hook": { "name": "inbound", "body": { "event": "ping" } }
38
+ },
39
+ "settings": { "greeting": "hello" },
40
+ "devSecrets": { "inbound_hook": "dev-only-value" }
41
+ }
42
+ ```
43
+
44
+ Without `contract.tool`, `test` calls the manifest's first tool as `user:alice` with `{}`.
45
+
46
+ ## Deploying
47
+
48
+ ```bash
49
+ export SIGHTLINE_URL=https://<your workspace>
50
+ export SIGHTLINE_DEPLOY_TOKEN=… # issued by a workspace admin; only ever read from the environment
51
+ npx sightline-ext deploy --bindings bindings.json
52
+ ```
53
+
54
+ | Variable | Meaning |
55
+ | --- | --- |
56
+ | `SIGHTLINE_URL` | The workspace. The API is `<url>/functions/v1/core/v1`. `https` only (`http` for localhost). |
57
+ | `SIGHTLINE_API_URL` | The full core API base instead, when it is not under `/functions/v1/core/v1`. |
58
+ | `SIGHTLINE_DEPLOY_TOKEN` | A deploy token (`slxd_…`) a workspace admin issued for your extension ids and actions, or an admin's own session token. A deploy token goes to the workspace's deploy routes (`/functions/v1/core/v1/deploy/…`), the only place it works. It is sent to that workspace only, never accepted as a flag, never in the URL. |
59
+ | `SIGHTLINE_API_KEY` | The gateway's public API key, when the workspace needs one (sent as `apikey`). |
60
+
61
+ `bindings.json` maps each permission your manifest declares to a Sightline permission the deploying admin holds: `{ "my-notes.read": "employees", "my-notes.write": "employees" }`. A permission left out gets the manifest's proposed `default` when the admin holds it.
62
+
63
+ The deploy publishes the server bundle and migrations. Publishing a UI (`ui.frames`) to the workspace's extension-UI host is not part of the release API yet.
64
+
65
+ ## Programmatic use
66
+
67
+ Everything the commands use is exported: `loadProject`, `build`, `createCoreApi`, `deploy`, `upgrade`, `uninstall`, `templateFiles`, `lintBundle`, `migrationProblems`.
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../dist/cli.js";
3
+
4
+ main(process.argv.slice(2)).then(
5
+ (code) => process.exit(code),
6
+ (e) => {
7
+ console.error(`sightline-ext: ${e instanceof Error ? e.message : String(e)}`);
8
+ process.exit(1);
9
+ },
10
+ );
package/dist/args.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type Flags = Record<string, string | true>;
2
+ export interface ParsedArgs {
3
+ positional: string[];
4
+ flags: Flags;
5
+ }
6
+ export declare function parseArgs(argv: string[]): ParsedArgs;
7
+ /** A string flag, or undefined (a bare `--flag` is not a value). */
8
+ export declare function str(flags: Flags, name: string): string | undefined;
9
+ /** A boolean flag: present bare, or `--flag=true|false`. */
10
+ export declare function bool(flags: Flags, name: string): boolean;
11
+ export declare function int(flags: Flags, name: string): number | undefined;
package/dist/args.js ADDED
@@ -0,0 +1,53 @@
1
+ // Argument parsing for sightline-ext: positionals, `--flag value`, `--flag=value`
2
+ // and bare `--flag` (true). No dependencies, so it runs under Node and Deno alike.
3
+ /** Flags that never take a value, so `--purge my-ext` keeps `my-ext` positional. */
4
+ const BOOLEAN_FLAGS = new Set(["purge", "yes", "force", "no-db", "no-build", "help", "json", "ui", "watch"]);
5
+ export function parseArgs(argv) {
6
+ const positional = [];
7
+ const flags = {};
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const a = argv[i];
10
+ if (a === "--") {
11
+ positional.push(...argv.slice(i + 1));
12
+ break;
13
+ }
14
+ if (a.startsWith("--")) {
15
+ const eq = a.indexOf("=");
16
+ if (eq > 0) {
17
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
18
+ continue;
19
+ }
20
+ const name = a.slice(2);
21
+ if (!BOOLEAN_FLAGS.has(name) && i + 1 < argv.length && !argv[i + 1].startsWith("--"))
22
+ flags[name] = argv[++i];
23
+ else
24
+ flags[name] = true;
25
+ }
26
+ else if (a === "-h") {
27
+ flags.help = true;
28
+ }
29
+ else {
30
+ positional.push(a);
31
+ }
32
+ }
33
+ return { positional, flags };
34
+ }
35
+ /** A string flag, or undefined (a bare `--flag` is not a value). */
36
+ export function str(flags, name) {
37
+ const v = flags[name];
38
+ return typeof v === "string" ? v : undefined;
39
+ }
40
+ /** A boolean flag: present bare, or `--flag=true|false`. */
41
+ export function bool(flags, name) {
42
+ const v = flags[name];
43
+ return v === true || v === "true" || v === "1";
44
+ }
45
+ export function int(flags, name) {
46
+ const v = str(flags, name);
47
+ if (v === undefined)
48
+ return undefined;
49
+ const n = Number(v);
50
+ if (!Number.isInteger(n) || n < 0 || n > 65_535)
51
+ throw new Error(`--${name} must be a port number`);
52
+ return n;
53
+ }
@@ -0,0 +1,56 @@
1
+ import { type MigrationFile, type Project, type UiFileOnDisk } from "./project.ts";
2
+ export interface BundleOptions {
3
+ entryPoints: string[];
4
+ outfile: string;
5
+ bundle: true;
6
+ format: "esm";
7
+ platform: "node";
8
+ target: string;
9
+ packages: "bundle";
10
+ external: string[];
11
+ legalComments: "none";
12
+ logLevel: "warning" | "info" | "silent";
13
+ absWorkingDir: string;
14
+ }
15
+ export type Bundler = (options: BundleOptions) => Promise<unknown>;
16
+ export type ManifestValidator = (manifest: unknown) => {
17
+ ok: boolean;
18
+ errors: {
19
+ path: string;
20
+ message: string;
21
+ }[];
22
+ };
23
+ /** The published contract's bundle lint (`lintBundle` from @sparelabs/sightline-extension-manifest). */
24
+ export type BundleLinter = (source: string) => {
25
+ errors: string[];
26
+ warnings: string[];
27
+ };
28
+ export interface BuildDeps {
29
+ bundler: Bundler;
30
+ validate: ManifestValidator;
31
+ lint: BundleLinter;
32
+ /** Core's bundle size limit (`MAX_BUNDLE_BYTES` from the manifest package). */
33
+ maxBundleBytes: number;
34
+ /** The published UI asset rules (`uiAssetProblems`); checks the UI build of a manifest with ui.frames. */
35
+ validateUi?: UiValidator;
36
+ }
37
+ export interface BuildResult {
38
+ bundle: Uint8Array;
39
+ bundlePath: string;
40
+ migrations: MigrationFile[];
41
+ /** The UI build to upload (ui.frames), or null. */
42
+ ui: UiFileOnDisk[] | null;
43
+ warnings: string[];
44
+ }
45
+ export declare function bundleOptions(project: Project): BundleOptions;
46
+ /** Manifest problems: the published schema and rules, then what core's publish adds. */
47
+ export declare function manifestProblems(project: Project, validate: ManifestValidator): string[];
48
+ /** The published contract's UI asset rules (`uiAssetProblems` from @sparelabs/sightline-extension-manifest). */
49
+ export type UiValidator = (files: UiFileOnDisk[], entry: string) => string[];
50
+ /**
51
+ * The UI build `deploy` uploads, for a manifest with ui.frames: every file
52
+ * under the UI directory, checked with core's own publish rules. Null when the
53
+ * manifest declares no frames.
54
+ */
55
+ export declare function collectUi(project: Project, validate?: UiValidator): UiFileOnDisk[] | null;
56
+ export declare function build(project: Project, deps: BuildDeps): Promise<BuildResult>;
package/dist/build.js ADDED
@@ -0,0 +1,73 @@
1
+ // `sightline-ext build`: validate the manifest against the published contract,
2
+ // check the migrations, bundle the server into one ESM file (the ext-runtime's
3
+ // bundle contract: no npm or remote imports at runtime; node: built-ins stay
4
+ // external), and lint the bundle the way core's publish does.
5
+ //
6
+ // The bundler, the manifest validator and the bundle lint are passed in, so
7
+ // this module has no package imports (the Deno tests run it with fakes; cli.ts
8
+ // wires esbuild and @sparelabs/sightline-extension-manifest, whose lintBundle
9
+ // and MAX_BUNDLE_BYTES are the very ones core's installer uses).
10
+ import { relative } from "node:path";
11
+ import { hostedRuleProblems, readMigrations, readUiFiles, uiFrames } from "./project.js";
12
+ export function bundleOptions(project) {
13
+ return {
14
+ entryPoints: [project.entry],
15
+ outfile: project.outfile,
16
+ bundle: true,
17
+ format: "esm",
18
+ platform: "node",
19
+ target: "es2022",
20
+ packages: "bundle",
21
+ external: ["node:*"],
22
+ legalComments: "none",
23
+ logLevel: "warning",
24
+ absWorkingDir: project.dir,
25
+ };
26
+ }
27
+ /** Manifest problems: the published schema and rules, then what core's publish adds. */
28
+ export function manifestProblems(project, validate) {
29
+ const result = validate(project.manifest);
30
+ const problems = result.errors.map((e) => `${e.path || "(root)"}: ${e.message}`);
31
+ if (problems.length === 0)
32
+ problems.push(...hostedRuleProblems(project.manifest));
33
+ return problems;
34
+ }
35
+ /**
36
+ * The UI build `deploy` uploads, for a manifest with ui.frames: every file
37
+ * under the UI directory, checked with core's own publish rules. Null when the
38
+ * manifest declares no frames.
39
+ */
40
+ export function collectUi(project, validate) {
41
+ const frames = uiFrames(project.manifest);
42
+ if (!frames)
43
+ return null;
44
+ const rel = relative(project.dir, project.uiDir) || project.uiDir;
45
+ if (!project.fs.isDirectory(project.uiDir)) {
46
+ throw new Error(`the manifest declares ui.frames, but there is no UI build at ${rel}: build your UI there first (or set "ui": { "dir": … } in sightline-ext.json)`);
47
+ }
48
+ const files = readUiFiles(project.uiDir, project.fs);
49
+ const problems = validate ? validate(files, frames.entry) : [];
50
+ if (problems.length)
51
+ throw new Error(`the UI build at ${rel} would be refused at publish:\n ${problems.join("\n ")}`);
52
+ return files;
53
+ }
54
+ export async function build(project, deps) {
55
+ const rel = (p) => relative(project.dir, p) || p;
56
+ const problems = manifestProblems(project, deps.validate);
57
+ if (problems.length)
58
+ throw new Error(`${rel(project.manifestPath)} does not meet the extension contract:\n ${problems.join("\n ")}`);
59
+ const migrations = readMigrations(project.migrationsDir, project.fs);
60
+ if (!project.fs.exists(project.entry))
61
+ throw new Error(`no server entry at ${rel(project.entry)} (set "entry" in sightline-ext.json)`);
62
+ const ui = collectUi(project, deps.validateUi);
63
+ await deps.bundler(bundleOptions(project));
64
+ const bundle = project.fs.readBytes(project.outfile);
65
+ if (bundle.length === 0)
66
+ throw new Error(`${rel(project.outfile)} is empty`);
67
+ if (bundle.length > deps.maxBundleBytes)
68
+ throw new Error(`${rel(project.outfile)} is ${bundle.length} bytes; core accepts at most ${deps.maxBundleBytes}`);
69
+ const lint = deps.lint(new TextDecoder().decode(bundle));
70
+ if (lint.errors.length)
71
+ throw new Error(`${rel(project.outfile)} would be refused at publish:\n ${lint.errors.join("\n ")}`);
72
+ return { bundle, bundlePath: project.outfile, migrations, ui, warnings: lint.warnings };
73
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare const USAGE = "sightline-ext <command> [options]\n\n init <dir> Scaffold a new extension repository\n [--id <kebab-id>] [--name \"Display name\"] [--publisher <kebab-id>] [--force]\n build Validate the manifest and migrations, bundle server/main.ts into dist/server.mjs\n dev Build, then run the extension against an emulated Sightline, rebuilding on change\n [--port <n>] [--core-port 8787] [--no-db]\n test Build, run the extension against the emulator, and check the HTTP contract\n [--no-build] [--no-db] [--keep-db]\n deploy Build, publish the release and install it in a workspace\n upgrade Build, publish the release and move the installed extension to it\n (deploy, upgrade: [--bindings bindings.json] [--no-build])\n uninstall Remove the extension from the workspace (its data is kept 30 days)\n [--purge --yes]\n\n Common: [--dir <project>] [--manifest sightline.extension.json] [--config sightline-ext.json]\n deploy, upgrade, uninstall: [--url https://<workspace>] [--api-url <core API base>]\n\nDeploying needs the workspace and a deploy token issued by one of its admins:\n SIGHTLINE_URL https://<your workspace> (or SIGHTLINE_API_URL, the full core API base)\n SIGHTLINE_DEPLOY_TOKEN the token (only ever read from the environment)\n SIGHTLINE_API_KEY the gateway's public api key, when your workspace needs one";
2
+ export type Out = (line: string) => void;
3
+ export declare function packageVersion(): string;
4
+ export declare function main(argv: string[], out?: Out): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,288 @@
1
+ // sightline-ext: scaffold, run, build, test and deploy a hosted Sightline extension.
2
+ // docs/platform/hosted-extensions.md → "The CLI".
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join, relative, resolve } from "node:path";
5
+ import { createCoreEmulator, freePort, listen, runExtension, startDatabase } from "@sparelabs/sightline-extension-devkit";
6
+ import { lintBundle, MAX_BUNDLE_BYTES, uiAssetProblems, validateManifest } from "@sparelabs/sightline-extension-manifest";
7
+ import { assertContract, formatReport, runContractTests } from "@sparelabs/sightline-extension-testing";
8
+ import { bool, int, parseArgs, str } from "./args.js";
9
+ import { build, bundleOptions } from "./build.js";
10
+ import { CoreApiError, createCoreApi, describeError } from "./core-api.js";
11
+ import { deploy, parseBindings, uninstall, upgrade } from "./deploy.js";
12
+ import { defaultContract, loadProject } from "./project.js";
13
+ import { templateFiles } from "./template.js";
14
+ export const USAGE = `sightline-ext <command> [options]
15
+
16
+ init <dir> Scaffold a new extension repository
17
+ [--id <kebab-id>] [--name "Display name"] [--publisher <kebab-id>] [--force]
18
+ build Validate the manifest and migrations, bundle server/main.ts into dist/server.mjs
19
+ dev Build, then run the extension against an emulated Sightline, rebuilding on change
20
+ [--port <n>] [--core-port 8787] [--no-db]
21
+ test Build, run the extension against the emulator, and check the HTTP contract
22
+ [--no-build] [--no-db] [--keep-db]
23
+ deploy Build, publish the release and install it in a workspace
24
+ upgrade Build, publish the release and move the installed extension to it
25
+ (deploy, upgrade: [--bindings bindings.json] [--no-build])
26
+ uninstall Remove the extension from the workspace (its data is kept 30 days)
27
+ [--purge --yes]
28
+
29
+ Common: [--dir <project>] [--manifest sightline.extension.json] [--config sightline-ext.json]
30
+ deploy, upgrade, uninstall: [--url https://<workspace>] [--api-url <core API base>]
31
+
32
+ Deploying needs the workspace and a deploy token issued by one of its admins:
33
+ SIGHTLINE_URL https://<your workspace> (or SIGHTLINE_API_URL, the full core API base)
34
+ SIGHTLINE_DEPLOY_TOKEN the token (only ever read from the environment)
35
+ SIGHTLINE_API_KEY the gateway's public api key, when your workspace needs one`;
36
+ /** The published contract's checks, the same functions core's installer runs at publish. */
37
+ const CONTRACT = { validate: validateManifest, lint: lintBundle, maxBundleBytes: MAX_BUNDLE_BYTES, validateUi: uiAssetProblems };
38
+ export function packageVersion() {
39
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
40
+ return pkg.version;
41
+ }
42
+ const esbuildBundler = async (options) => {
43
+ const esbuild = await import("esbuild");
44
+ await esbuild.build(options);
45
+ };
46
+ function project(flags) {
47
+ return loadProject(str(flags, "dir") ?? ".", { manifest: str(flags, "manifest"), config: str(flags, "config"), entry: str(flags, "entry") });
48
+ }
49
+ async function buildProject(p, out) {
50
+ const result = await build(p, { bundler: esbuildBundler, ...CONTRACT });
51
+ out(`built ${relative(process.cwd(), result.bundlePath) || result.bundlePath} (${result.bundle.length} bytes; ${result.migrations.length} migration(s)); manifest ${p.id}@${p.version} is valid`);
52
+ if (result.ui)
53
+ out(` UI: ${result.ui.length} file(s) from ${relative(process.cwd(), p.uiDir) || p.uiDir}, uploaded with the release on deploy`);
54
+ for (const w of result.warnings)
55
+ out(` warning: ${w}`);
56
+ return result;
57
+ }
58
+ function secretEnv(p) {
59
+ const env = {};
60
+ for (const [k, v] of Object.entries(process.env))
61
+ if (k.startsWith("SL_SECRET_") && v !== undefined)
62
+ env[k] = v;
63
+ for (const [name, value] of Object.entries(p.config.devSecrets ?? {})) {
64
+ if (!/^[a-z][a-z0-9_-]{0,63}$/i.test(name) || typeof value !== "string")
65
+ throw new Error(`devSecrets: "${name}" must be a secret name with a string value`);
66
+ env[`SL_SECRET_${name.toUpperCase().replace(/-/g, "_")}`] = value;
67
+ }
68
+ return env;
69
+ }
70
+ const hasMigrations = (p) => existsSync(p.migrationsDir) && readdirSync(p.migrationsDir).some((f) => f.endsWith(".sql"));
71
+ function waitForSignal() {
72
+ return new Promise((done) => {
73
+ process.once("SIGINT", () => done());
74
+ process.once("SIGTERM", () => done());
75
+ });
76
+ }
77
+ // ── commands ─────────────────────────────────────────────────────────────────
78
+ function init(positional, flags, out) {
79
+ const target = positional[0];
80
+ if (!target)
81
+ throw new Error("usage: sightline-ext init <dir> [--id <kebab-id>]");
82
+ const dir = resolve(target);
83
+ const id = str(flags, "id") ?? basename(dir);
84
+ const files = templateFiles({ id, name: str(flags, "name"), publisher: str(flags, "publisher"), packagesVersion: packageVersion() });
85
+ if (existsSync(dir) && readdirSync(dir).length > 0 && !bool(flags, "force")) {
86
+ throw new Error(`${target} is not empty (pass --force to write into it anyway; existing files with the same names are replaced)`);
87
+ }
88
+ for (const [path, content] of Object.entries(files)) {
89
+ const file = join(dir, path);
90
+ mkdirSync(dirname(file), { recursive: true });
91
+ writeFileSync(file, content);
92
+ }
93
+ out(`created ${id} in ${target}:`);
94
+ for (const path of Object.keys(files))
95
+ out(` ${path}`);
96
+ out(`\nnext:\n cd ${target}\n npm install\n npm test # needs Deno and Docker`);
97
+ return 0;
98
+ }
99
+ async function dev(flags, out) {
100
+ const p = project(flags);
101
+ await buildProject(p, out);
102
+ const emulator = await createCoreEmulator({ extension: p.id, version: p.version, settings: p.config.settings ?? {} });
103
+ const core = await listen(emulator.handler, { port: int(flags, "core-port") ?? 8787 });
104
+ emulator.setOrigin(core.url);
105
+ const env = emulator.runtimeEnv(secretEnv(p));
106
+ let db = null;
107
+ if (!bool(flags, "no-db") && hasMigrations(p)) {
108
+ db = await startDatabase({ extension: p.id, migrationsDir: p.migrationsDir });
109
+ env.SL_DB_URL = db.url;
110
+ out(`database ${db.container} (${db.database}; applied ${db.applied.length ? db.applied.join(", ") : "nothing new"})`);
111
+ }
112
+ const port = int(flags, "port") ?? (await freePort());
113
+ let ext = await runExtension({ bundle: p.outfile, env, port });
114
+ // The devkit's state file, so `npx sightline-devkit invoke …` finds this run.
115
+ mkdirSync(join(p.dir, ".sightline-devkit"), { recursive: true });
116
+ writeFileSync(join(p.dir, ".sightline-devkit", "state.json"), `${JSON.stringify({ coreUrl: core.url, extensionUrl: ext.url }, null, 2)}\n`);
117
+ out(`core ${core.url} (emulated: JWKS, ext-api, /__devkit/token)`);
118
+ out(`extension ${ext.url} (Sightline's runtime flags)`);
119
+ const firstTool = Array.isArray(p.manifest.tools) ? p.manifest.tools.find((t) => typeof t?.name === "string")?.name : undefined;
120
+ out(firstTool ? `try npx sightline-devkit invoke tool ${String(firstTool)} --as user:alice` : "try npx sightline-devkit invoke api /<path> --as user:alice");
121
+ out("watching for changes; Ctrl-C stops");
122
+ const esbuild = await import("esbuild");
123
+ let restarting = Promise.resolve();
124
+ const ctx = await esbuild.context({
125
+ ...bundleOptions(p),
126
+ plugins: [{
127
+ name: "sightline-ext-restart",
128
+ setup(b) {
129
+ let first = true;
130
+ b.onEnd((result) => {
131
+ if (first) {
132
+ first = false;
133
+ return;
134
+ }
135
+ if (result.errors.length)
136
+ return;
137
+ const lint = lintBundle(readFileSync(p.outfile, "utf8"));
138
+ if (lint.errors.length) {
139
+ out(`rebuilt, but the bundle would be refused at publish: ${lint.errors.join("; ")}`);
140
+ return;
141
+ }
142
+ restarting = restarting.then(async () => {
143
+ await ext.stop();
144
+ ext = await runExtension({ bundle: p.outfile, env, port });
145
+ out(`rebuilt and restarted ${ext.url}`);
146
+ }).catch((e) => out(`restart failed: ${e.message}`));
147
+ });
148
+ },
149
+ }],
150
+ });
151
+ await ctx.watch();
152
+ await waitForSignal();
153
+ await ctx.dispose();
154
+ await restarting;
155
+ await ext.stop();
156
+ await core.close();
157
+ out(db ? `stopped (the database container ${db.container} keeps running; \`npx sightline-devkit db down\` removes it)` : "stopped");
158
+ return 0;
159
+ }
160
+ async function test(flags, out) {
161
+ const p = project(flags);
162
+ if (!bool(flags, "no-build"))
163
+ await buildProject(p, out);
164
+ else if (!existsSync(p.outfile))
165
+ throw new Error(`--no-build, but ${p.outfile} does not exist`);
166
+ const contract = defaultContract(p);
167
+ const emulator = await createCoreEmulator({ extension: p.id, version: p.version, settings: p.config.settings ?? {} });
168
+ const core = await listen(emulator.handler);
169
+ emulator.setOrigin(core.url);
170
+ const env = emulator.runtimeEnv(secretEnv(p));
171
+ let db = null;
172
+ let ext = null;
173
+ try {
174
+ if (!bool(flags, "no-db") && hasMigrations(p)) {
175
+ db = await startDatabase({ extension: p.id, migrationsDir: p.migrationsDir, container: `sightline-ext-test-${p.id}-${process.pid}` });
176
+ env.SL_DB_URL = db.url;
177
+ out(`database ${db.database}: applied ${db.applied.join(", ") || "nothing"}`);
178
+ }
179
+ ext = await runExtension({ bundle: p.outfile, env, port: await freePort() });
180
+ const report = await runContractTests(ext.url, {
181
+ coreUrl: core.url,
182
+ tool: contract.tool,
183
+ job: contract.job,
184
+ hook: contract.hook,
185
+ expect: { version: p.version },
186
+ });
187
+ out(formatReport(report));
188
+ try {
189
+ assertContract(report);
190
+ }
191
+ catch {
192
+ return 1;
193
+ }
194
+ out(`${p.id}@${p.version} meets the contract`);
195
+ return 0;
196
+ }
197
+ finally {
198
+ await ext?.stop();
199
+ await core.close();
200
+ if (db && !bool(flags, "keep-db"))
201
+ await db.stop();
202
+ }
203
+ }
204
+ function coreApiFromEnv(flags, env = process.env) {
205
+ const token = env.SIGHTLINE_DEPLOY_TOKEN;
206
+ if (!token)
207
+ throw new Error("set SIGHTLINE_DEPLOY_TOKEN to a deploy token from a workspace admin (it is only read from the environment)");
208
+ // --url / --api-url override SIGHTLINE_URL / SIGHTLINE_API_URL; the token never comes from a flag.
209
+ const url = str(flags, "url") ?? env.SIGHTLINE_URL;
210
+ const apiUrl = str(flags, "api-url") ?? (str(flags, "url") ? undefined : env.SIGHTLINE_API_URL);
211
+ return createCoreApi({ url, apiUrl, token, apiKey: env.SIGHTLINE_API_KEY });
212
+ }
213
+ function readBindings(flags) {
214
+ const file = str(flags, "bindings");
215
+ if (!file)
216
+ return undefined;
217
+ return parseBindings(JSON.parse(readFileSync(file, "utf8")));
218
+ }
219
+ async function release(flags, out) {
220
+ const p = project(flags);
221
+ let built;
222
+ if (bool(flags, "no-build")) {
223
+ if (!existsSync(p.outfile))
224
+ throw new Error(`--no-build, but ${p.outfile} does not exist`);
225
+ built = await build(p, { bundler: async () => undefined, ...CONTRACT });
226
+ }
227
+ else {
228
+ built = await buildProject(p, out);
229
+ }
230
+ return { manifest: p.manifest, bundle: built.bundle, migrations: built.migrations, ui: built.ui };
231
+ }
232
+ export async function main(argv, out = console.log) {
233
+ const { positional, flags } = parseArgs(argv);
234
+ const [command, ...rest] = positional;
235
+ if (!command && flags.version === true) {
236
+ out(packageVersion());
237
+ return 0;
238
+ }
239
+ try {
240
+ switch (command) {
241
+ case "init":
242
+ return init(rest, flags, out);
243
+ case "build":
244
+ await buildProject(project(flags), out);
245
+ return 0;
246
+ case "dev":
247
+ return await dev(flags, out);
248
+ case "test":
249
+ return await test(flags, out);
250
+ case "deploy": {
251
+ const api = coreApiFromEnv(flags);
252
+ await deploy(api, await release(flags, out), { bindings: readBindings(flags) }, out);
253
+ return 0;
254
+ }
255
+ case "upgrade": {
256
+ const api = coreApiFromEnv(flags);
257
+ await upgrade(api, await release(flags, out), { bindings: readBindings(flags) }, out);
258
+ return 0;
259
+ }
260
+ case "uninstall": {
261
+ const purge = bool(flags, "purge");
262
+ if (purge && !bool(flags, "yes"))
263
+ throw new Error("--purge drops the extension's data now and cannot be undone; add --yes to confirm");
264
+ const api = coreApiFromEnv(flags);
265
+ const id = rest[0] ?? project(flags).id;
266
+ await uninstall(api, id, { purge }, out);
267
+ return 0;
268
+ }
269
+ case "version":
270
+ out(packageVersion());
271
+ return 0;
272
+ case undefined:
273
+ case "help":
274
+ out(USAGE);
275
+ return command ? 0 : bool(flags, "help") ? 0 : 2;
276
+ default:
277
+ out(`unknown command ${command}\n\n${USAGE}`);
278
+ return 2;
279
+ }
280
+ }
281
+ catch (e) {
282
+ if (e instanceof CoreApiError) {
283
+ out(`error: ${describeError(e)}`);
284
+ return 1;
285
+ }
286
+ throw e;
287
+ }
288
+ }