@sparelabs/sightline-extension-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@ You need Node 20.19+, [Deno](https://deno.com) (the extension runtime) and Docke
21
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
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
23
  | `uninstall [<id>] [--purge --yes]` | Uninstalls. Data is kept 30 days; `--purge --yes` drops it now. |
24
+ | `webhooks send <name> --body <file> [--url <webhook URL>] [--secret-env VAR] [--content-type …] [--timestamp <unix s>]` | Signs the file's raw bytes the way Sightline verifies the manifest webhook `<name>` (HMAC-SHA256 hex after its `prefix`, over `<timestamp>.<raw body>` when it declares a `timestampHeader`) and POSTs them. Without `--url` it goes to the running `dev`, whose emulator verifies it like Sightline (401 with the reason when it fails, 409 for a replay) and forwards it to your `/hooks/<name>`; with `--url` (the webhook URL from the workspace's Setup) to a real workspace. The secret comes from `SL_SECRET_<NAME>` or `--secret-env VAR`, never from a flag; locally `devSecrets` win over the environment, as `dev` injects them, so the delivery is signed with the value the emulator verifies with. Exit 1 on a non-2xx answer. |
24
25
 
25
26
  Common options: `--dir <project>`, `--manifest <file>`, `--config <file>`, `--entry <file>`.
26
27
 
@@ -64,7 +65,7 @@ The deploy publishes the server bundle and migrations. Publishing a UI (`ui.fram
64
65
 
65
66
  ## Programmatic use
66
67
 
67
- Everything the commands use is exported: `loadProject`, `build`, `createCoreApi`, `deploy`, `upgrade`, `uninstall`, `templateFiles`, `lintBundle`, `migrationProblems`.
68
+ Everything the commands use is exported: `loadProject`, `build`, `createCoreApi`, `deploy`, `upgrade`, `uninstall`, `templateFiles`, `lintBundle`, `migrationProblems`, `planWebhookSend`.
68
69
 
69
70
  ## License
70
71
 
package/dist/cli.d.ts CHANGED
@@ -1,4 +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";
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 webhooks send <name> --body <file>\n Sign the file's raw bytes the way Sightline verifies them and POST them: to the\n running `dev` (verified and forwarded to your /hooks/<name>), or with\n --url <webhook URL from Setup> to a workspace. The secret is read from\n SL_SECRET_<NAME> (or --secret-env VAR, or devSecrets), never from a flag.\n [--url <url>] [--secret-env VAR] [--content-type application/json] [--timestamp <unix s>]\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
2
  export type Out = (line: string) => void;
3
3
  export declare function packageVersion(): string;
4
4
  export declare function main(argv: string[], out?: Out): Promise<number>;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // docs/platform/hosted-extensions.md → "The CLI".
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, relative, resolve } from "node:path";
5
- import { createCoreEmulator, freePort, listen, runExtension, startDatabase } from "@sparelabs/sightline-extension-devkit";
5
+ import { createCoreEmulator, freePort, listen, runExtension, sendWebhook, startDatabase, webhookSignature } from "@sparelabs/sightline-extension-devkit";
6
6
  import { lintBundle, MAX_BUNDLE_BYTES, uiAssetProblems, validateManifest } from "@sparelabs/sightline-extension-manifest";
7
7
  import { assertContract, formatReport, runContractTests } from "@sparelabs/sightline-extension-testing";
8
8
  import { bool, int, parseArgs, str } from "./args.js";
@@ -11,6 +11,7 @@ import { CoreApiError, createCoreApi, describeError } from "./core-api.js";
11
11
  import { deploy, parseBindings, uninstall, upgrade } from "./deploy.js";
12
12
  import { defaultContract, loadProject } from "./project.js";
13
13
  import { templateFiles } from "./template.js";
14
+ import { planWebhookSend } from "./webhooks.js";
14
15
  export const USAGE = `sightline-ext <command> [options]
15
16
 
16
17
  init <dir> Scaffold a new extension repository
@@ -25,6 +26,12 @@ export const USAGE = `sightline-ext <command> [options]
25
26
  (deploy, upgrade: [--bindings bindings.json] [--no-build])
26
27
  uninstall Remove the extension from the workspace (its data is kept 30 days)
27
28
  [--purge --yes]
29
+ webhooks send <name> --body <file>
30
+ Sign the file's raw bytes the way Sightline verifies them and POST them: to the
31
+ running \`dev\` (verified and forwarded to your /hooks/<name>), or with
32
+ --url <webhook URL from Setup> to a workspace. The secret is read from
33
+ SL_SECRET_<NAME> (or --secret-env VAR, or devSecrets), never from a flag.
34
+ [--url <url>] [--secret-env VAR] [--content-type application/json] [--timestamp <unix s>]
28
35
 
29
36
  Common: [--dir <project>] [--manifest sightline.extension.json] [--config sightline-ext.json]
30
37
  deploy, upgrade, uninstall: [--url https://<workspace>] [--api-url <core API base>]
@@ -99,10 +106,17 @@ function init(positional, flags, out) {
99
106
  async function dev(flags, out) {
100
107
  const p = project(flags);
101
108
  await buildProject(p, out);
102
- const emulator = await createCoreEmulator({ extension: p.id, version: p.version, settings: p.config.settings ?? {} });
109
+ const emulator = await createCoreEmulator({
110
+ extension: p.id,
111
+ version: p.version,
112
+ settings: p.config.settings ?? {},
113
+ // Its ext-hooks route verifies webhooks with these, as core does with the installation's secrets.
114
+ manifest: p.manifest,
115
+ secrets: secretEnv(p),
116
+ });
103
117
  const core = await listen(emulator.handler, { port: int(flags, "core-port") ?? 8787 });
104
118
  emulator.setOrigin(core.url);
105
- const env = emulator.runtimeEnv(secretEnv(p));
119
+ const env = emulator.runtimeEnv();
106
120
  let db = null;
107
121
  if (!bool(flags, "no-db") && hasMigrations(p)) {
108
122
  db = await startDatabase({ extension: p.id, migrationsDir: p.migrationsDir });
@@ -111,6 +125,7 @@ async function dev(flags, out) {
111
125
  }
112
126
  const port = int(flags, "port") ?? (await freePort());
113
127
  let ext = await runExtension({ bundle: p.outfile, env, port });
128
+ emulator.setExtensionUrl(ext.url);
114
129
  // The devkit's state file, so `npx sightline-devkit invoke …` finds this run.
115
130
  mkdirSync(join(p.dir, ".sightline-devkit"), { recursive: true });
116
131
  writeFileSync(join(p.dir, ".sightline-devkit", "state.json"), `${JSON.stringify({ coreUrl: core.url, extensionUrl: ext.url }, null, 2)}\n`);
@@ -118,6 +133,9 @@ async function dev(flags, out) {
118
133
  out(`extension ${ext.url} (Sightline's runtime flags)`);
119
134
  const firstTool = Array.isArray(p.manifest.tools) ? p.manifest.tools.find((t) => typeof t?.name === "string")?.name : undefined;
120
135
  out(firstTool ? `try npx sightline-devkit invoke tool ${String(firstTool)} --as user:alice` : "try npx sightline-devkit invoke api /<path> --as user:alice");
136
+ const firstHook = Array.isArray(p.manifest.webhooks) ? p.manifest.webhooks.find((w) => typeof w?.name === "string")?.name : undefined;
137
+ if (firstHook)
138
+ out(`webhooks npx sightline-ext webhooks send ${String(firstHook)} --body event.json (signed, verified like Sightline, forwarded)`);
121
139
  out("watching for changes; Ctrl-C stops");
122
140
  const esbuild = await import("esbuild");
123
141
  let restarting = Promise.resolve();
@@ -201,6 +219,34 @@ async function test(flags, out) {
201
219
  await db.stop();
202
220
  }
203
221
  }
222
+ async function webhooks(positional, flags, out) {
223
+ const [sub, name] = positional;
224
+ if (sub !== "send" || !name)
225
+ throw new Error("usage: sightline-ext webhooks send <name> --body <file> [--url <webhook URL>]");
226
+ const p = project(flags);
227
+ const hook = webhookSignature(p.manifest, name);
228
+ let localCoreUrl;
229
+ try {
230
+ localCoreUrl = JSON.parse(readFileSync(join(p.dir, ".sightline-devkit", "state.json"), "utf8")).coreUrl;
231
+ }
232
+ catch {
233
+ localCoreUrl = undefined;
234
+ }
235
+ const plan = planWebhookSend({
236
+ hook: name,
237
+ secretName: hook.secret,
238
+ flags,
239
+ env: process.env,
240
+ devSecrets: p.config.devSecrets,
241
+ readFile: (path) => new Uint8Array(readFileSync(resolve(path))),
242
+ localCoreUrl,
243
+ });
244
+ const result = await sendWebhook({ url: plan.url, hook, secret: plan.secret, body: plan.body, contentType: plan.contentType, timestamp: plan.timestamp });
245
+ out(`POST ${plan.url} (${plan.body.length} bytes, signed with ${plan.secretFrom}${hook.timestampHeader ? `, ${hook.timestampHeader} ${result.headers[hook.timestampHeader]}` : ""})`);
246
+ out(`HTTP ${result.status}`);
247
+ out(typeof result.body === "string" ? result.body : JSON.stringify(result.body, null, 2));
248
+ return result.status >= 200 && result.status < 300 ? 0 : 1;
249
+ }
204
250
  function coreApiFromEnv(flags, env = process.env) {
205
251
  const token = env.SIGHTLINE_DEPLOY_TOKEN;
206
252
  if (!token)
@@ -266,6 +312,8 @@ export async function main(argv, out = console.log) {
266
312
  await uninstall(api, id, { purge }, out);
267
313
  return 0;
268
314
  }
315
+ case "webhooks":
316
+ return await webhooks(rest, flags, out);
269
317
  case "version":
270
318
  out(packageVersion());
271
319
  return 0;
package/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export { deploy, parseBindings, uninstall, upgrade } from "./deploy.ts";
6
6
  export { CONFIG_FILE, type ContractConfig, defaultContract, hostedRuleProblems, loadProject, MANIFEST_FILE, migrationProblems, type MigrationFile, type Project, type ProjectConfig, readMigrations, readUiFiles, type UiFileOnDisk, uiFrames, } from "./project.ts";
7
7
  export { lintBundle, MAX_BUNDLE_BYTES } from "@sparelabs/sightline-extension-manifest";
8
8
  export { templateFiles, type TemplateOptions, templateProblems, toolPrefix } from "./template.ts";
9
+ export { planWebhookSend, secretVar, type WebhookSendInput, type WebhookSendPlan } from "./webhooks.ts";
package/dist/index.js CHANGED
@@ -10,3 +10,4 @@ export { CONFIG_FILE, defaultContract, hostedRuleProblems, loadProject, MANIFEST
10
10
  // The bundle lint is the published contract's, the same function core's installer runs.
11
11
  export { lintBundle, MAX_BUNDLE_BYTES } from "@sparelabs/sightline-extension-manifest";
12
12
  export { templateFiles, templateProblems, toolPrefix } from "./template.js";
13
+ export { planWebhookSend, secretVar } from "./webhooks.js";
@@ -2,7 +2,7 @@ export interface TemplateOptions {
2
2
  id: string;
3
3
  name?: string;
4
4
  publisher?: string;
5
- /** The published packages' version (the CLI's own); dependencies use ^<version>. */
5
+ /** The CLI's own version. The packages share MAJOR.MINOR (patches are per package), so dependencies use ^MAJOR.MINOR.0. */
6
6
  packagesVersion: string;
7
7
  }
8
8
  /** kebab-case id → the snake_case prefix of its tool names. */
package/dist/template.js CHANGED
@@ -22,7 +22,8 @@ export function templateFiles(options) {
22
22
  const name = options.name ?? id.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
23
23
  const publisher = options.publisher ?? id;
24
24
  const p = toolPrefix(id);
25
- const range = `^${packagesVersion}`;
25
+ const [major, minor] = packagesVersion.split(".");
26
+ const range = `^${major}.${minor}.0`;
26
27
  const manifest = {
27
28
  manifestVersion: 2,
28
29
  id,
@@ -0,0 +1,31 @@
1
+ import { type Flags } from "./args.ts";
2
+ export interface WebhookSendPlan {
3
+ /** The local emulator's ext-hooks route, or the workspace's webhook URL (`--url`, from Setup). */
4
+ url: string;
5
+ /** The environment variable the secret came from (never the value). */
6
+ secretFrom: string;
7
+ secret: string;
8
+ body: Uint8Array;
9
+ contentType: string;
10
+ timestamp?: number;
11
+ }
12
+ export interface WebhookSendInput {
13
+ hook: string;
14
+ /** The manifest secret that signs this webhook (`verify.secret`). */
15
+ secretName: string;
16
+ flags: Flags;
17
+ env: Record<string, string | undefined>;
18
+ /** sightline-ext.json `devSecrets`: the values `dev` and `test` inject. */
19
+ devSecrets?: Record<string, string>;
20
+ readFile(path: string): Uint8Array;
21
+ /** The running `sightline-ext dev`'s emulator (.sightline-devkit/state.json), if any. */
22
+ localCoreUrl?: string;
23
+ }
24
+ /** `inbound_hook` → `SL_SECRET_INBOUND_HOOK`. */
25
+ export declare function secretVar(secretName: string): string;
26
+ /**
27
+ * Resolve a send. The secret is read from the environment (`--secret-env`, default
28
+ * `SL_SECRET_<NAME>`) or sightline-ext.json `devSecrets`, never from a flag value,
29
+ * so it stays out of shell history.
30
+ */
31
+ export declare function planWebhookSend(input: WebhookSendInput): WebhookSendPlan;
@@ -0,0 +1,72 @@
1
+ // `sightline-ext webhooks send <name> --body <file>`: where a signed test delivery
2
+ // goes and what signs it. The signing itself is the devkit's `signWebhook`, which
3
+ // signs `<timestamp>.<raw body>` (or the raw body) exactly like core's
4
+ // `signHookDelivery` (docs/platform/hosted-extensions-hooks-secrets.md).
5
+ import { str } from "./args.js";
6
+ const ENV_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
7
+ /** `inbound_hook` → `SL_SECRET_INBOUND_HOOK`. */
8
+ export function secretVar(secretName) {
9
+ return `SL_SECRET_${secretName.toUpperCase().replace(/-/g, "_")}`;
10
+ }
11
+ /**
12
+ * Resolve a send. The secret is read from the environment (`--secret-env`, default
13
+ * `SL_SECRET_<NAME>`) or sightline-ext.json `devSecrets`, never from a flag value,
14
+ * so it stays out of shell history.
15
+ */
16
+ export function planWebhookSend(input) {
17
+ const bodyFile = str(input.flags, "body");
18
+ if (!bodyFile)
19
+ throw new Error("usage: sightline-ext webhooks send <name> --body <file> [--url <webhook URL>]");
20
+ const explicitVar = str(input.flags, "secret-env");
21
+ if (explicitVar !== undefined && !ENV_NAME_RE.test(explicitVar))
22
+ throw new Error("--secret-env names an environment variable, e.g. SL_SECRET_INBOUND_HOOK");
23
+ const variable = explicitVar ?? secretVar(input.secretName);
24
+ let secret = input.env[variable];
25
+ let secretFrom = `$${variable}`;
26
+ // Locally, the same precedence `dev` uses for the emulator and the runtime (devSecrets over the
27
+ // environment), so the delivery is signed with the value it is verified with. A workspace target
28
+ // (--url) never uses devSecrets: those are local values.
29
+ const devValue = input.devSecrets?.[input.secretName];
30
+ if (str(input.flags, "url") === undefined && explicitVar === undefined && typeof devValue === "string" && devValue !== "") {
31
+ secret = devValue;
32
+ secretFrom = `sightline-ext.json devSecrets.${input.secretName}`;
33
+ }
34
+ if (!secret) {
35
+ throw new Error(`no value for the "${input.secretName}" secret: set ${variable} (the workspace's value for a real target; it is never read from a flag)`);
36
+ }
37
+ let url = str(input.flags, "url");
38
+ if (url !== undefined) {
39
+ let parsed;
40
+ try {
41
+ parsed = new URL(url);
42
+ }
43
+ catch {
44
+ throw new Error("--url must be the webhook URL, e.g. https://<workspace>/functions/v1/ext-hooks/i/<token>/<hook>");
45
+ }
46
+ const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
47
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && local))
48
+ throw new Error("--url must be https (http only for localhost)");
49
+ if (parsed.username || parsed.password)
50
+ throw new Error("--url must not carry credentials");
51
+ }
52
+ else {
53
+ if (!input.localCoreUrl)
54
+ throw new Error("no running `sightline-ext dev` here: start it, or pass --url <webhook URL> to send to a workspace");
55
+ url = `${input.localCoreUrl.replace(/\/+$/, "")}/functions/v1/ext-hooks/i/devkit/${input.hook}`;
56
+ }
57
+ const ts = str(input.flags, "timestamp");
58
+ let timestamp;
59
+ if (ts !== undefined) {
60
+ timestamp = Number(ts);
61
+ if (!Number.isInteger(timestamp) || timestamp < 0 || timestamp > 999_999_999_999)
62
+ throw new Error("--timestamp is Unix seconds");
63
+ }
64
+ return {
65
+ url,
66
+ secretFrom,
67
+ secret,
68
+ body: input.readFile(bodyFile),
69
+ contentType: str(input.flags, "content-type") ?? "application/json",
70
+ timestamp,
71
+ };
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparelabs/sightline-extension-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "sightline-ext: scaffold, run, build, test and deploy a hosted Sightline extension.",
5
5
  "license": "MIT",
6
6
  "author": "Spare Labs Inc.",
@@ -34,8 +34,8 @@
34
34
  "typecheck": "tsc -p tsconfig.json"
35
35
  },
36
36
  "dependencies": {
37
- "@sparelabs/sightline-extension-devkit": "0.1.0",
38
- "@sparelabs/sightline-extension-manifest": "0.1.0",
37
+ "@sparelabs/sightline-extension-devkit": "0.1.1",
38
+ "@sparelabs/sightline-extension-manifest": "0.1.2",
39
39
  "@sparelabs/sightline-extension-testing": "0.1.0",
40
40
  "esbuild": "^0.25.10"
41
41
  },