@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 +21 -0
- package/README.md +71 -0
- package/bin/sightline-ext.mjs +10 -0
- package/dist/args.d.ts +11 -0
- package/dist/args.js +53 -0
- package/dist/build.d.ts +56 -0
- package/dist/build.js +73 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +288 -0
- package/dist/core-api.d.ts +98 -0
- package/dist/core-api.js +166 -0
- package/dist/deploy.d.ts +13 -0
- package/dist/deploy.js +71 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/project.d.ts +99 -0
- package/dist/project.js +165 -0
- package/dist/template.d.ts +11 -0
- package/dist/template.js +221 -0
- package/package.json +53 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export interface CoreApiOptions {
|
|
2
|
+
/** The workspace's URL, e.g. https://sightline.example.com (the API is under /functions/v1/core/v1). */
|
|
3
|
+
url?: string;
|
|
4
|
+
/** The full API base instead, e.g. https://api.example.com/functions/v1/core/v1. */
|
|
5
|
+
apiUrl?: string;
|
|
6
|
+
token: string;
|
|
7
|
+
/** Sent as `apikey` when the workspace's gateway wants one. */
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
fetch?: typeof fetch;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ReleaseView {
|
|
13
|
+
extensionId: string;
|
|
14
|
+
version: string;
|
|
15
|
+
manifestSha256: string;
|
|
16
|
+
bundleSha256: string;
|
|
17
|
+
coreApiCompatible?: boolean;
|
|
18
|
+
permissions?: {
|
|
19
|
+
key: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
default?: string | null;
|
|
22
|
+
}[];
|
|
23
|
+
checks?: {
|
|
24
|
+
warnings?: string[];
|
|
25
|
+
ui?: {
|
|
26
|
+
files: number;
|
|
27
|
+
bytes: number;
|
|
28
|
+
sha256: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
export interface InstallationView {
|
|
34
|
+
extensionId: string;
|
|
35
|
+
installationId: string;
|
|
36
|
+
state: string;
|
|
37
|
+
version: string;
|
|
38
|
+
bindings?: Record<string, string>;
|
|
39
|
+
lastError?: string | null;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
export interface PublishInput {
|
|
43
|
+
manifest: Record<string, unknown>;
|
|
44
|
+
bundle: Uint8Array;
|
|
45
|
+
migrations: {
|
|
46
|
+
file: string;
|
|
47
|
+
sql: string;
|
|
48
|
+
}[];
|
|
49
|
+
/** The UI build (ui.frames), uploaded with the release: each file's path relative to the build and its bytes. */
|
|
50
|
+
ui?: {
|
|
51
|
+
path: string;
|
|
52
|
+
bytes: Uint8Array;
|
|
53
|
+
}[] | null;
|
|
54
|
+
}
|
|
55
|
+
export declare class CoreApiError extends Error {
|
|
56
|
+
readonly status: number;
|
|
57
|
+
readonly code: string;
|
|
58
|
+
readonly details: Record<string, unknown>;
|
|
59
|
+
constructor(status: number, code: string, message: string, details?: Record<string, unknown>);
|
|
60
|
+
}
|
|
61
|
+
export declare function apiBase(options: Pick<CoreApiOptions, "url" | "apiUrl">): string;
|
|
62
|
+
export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
|
|
63
|
+
/** A human sentence for an installer error, with the details that tell you what to do. */
|
|
64
|
+
export declare function describeError(err: CoreApiError): string;
|
|
65
|
+
export interface CoreApi {
|
|
66
|
+
readonly base: string;
|
|
67
|
+
list(): Promise<{
|
|
68
|
+
installations: InstallationView[];
|
|
69
|
+
releases: ReleaseView[];
|
|
70
|
+
}>;
|
|
71
|
+
publish(input: PublishInput): Promise<{
|
|
72
|
+
release: ReleaseView;
|
|
73
|
+
created: boolean;
|
|
74
|
+
}>;
|
|
75
|
+
install(id: string, body: {
|
|
76
|
+
version: string;
|
|
77
|
+
bindings?: Record<string, string>;
|
|
78
|
+
}): Promise<InstallationView>;
|
|
79
|
+
upgrade(id: string, body: {
|
|
80
|
+
version: string;
|
|
81
|
+
bindings?: Record<string, string>;
|
|
82
|
+
}): Promise<InstallationView>;
|
|
83
|
+
uninstall(id: string, body: {
|
|
84
|
+
purge?: boolean;
|
|
85
|
+
}): Promise<{
|
|
86
|
+
installation: InstallationView;
|
|
87
|
+
warnings: string[];
|
|
88
|
+
}>;
|
|
89
|
+
}
|
|
90
|
+
/** A deploy token an admin issued (`slxd_…`), as opposed to an admin's own session token. */
|
|
91
|
+
export declare function isDeployToken(token: string): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Where the lifecycle routes are for this token. A deploy token (`slxd_…`) is
|
|
94
|
+
* accepted only under `<core>/deploy` (the one hosted path CI reaches past the
|
|
95
|
+
* workspace's sign-in proxy); an admin's session token uses the admin routes.
|
|
96
|
+
*/
|
|
97
|
+
export declare function lifecycleBase(coreBase: string, token: string): string;
|
|
98
|
+
export declare function createCoreApi(options: CoreApiOptions): CoreApi;
|
package/dist/core-api.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// A client for core's hosted-extension lifecycle API
|
|
2
|
+
// (docs/platform/hosted-extensions-registry.md → "The core routes"):
|
|
3
|
+
//
|
|
4
|
+
// GET /core/v1/extensions { installations, releases }
|
|
5
|
+
// POST /core/v1/extensions/releases { manifest, bundle: { base64, sha256 }, migrations? }
|
|
6
|
+
// POST /core/v1/extensions/:id/install { version, bindings? }
|
|
7
|
+
// POST /core/v1/extensions/:id/upgrade { version, bindings? }
|
|
8
|
+
// POST /core/v1/extensions/:id/uninstall { purge? }
|
|
9
|
+
//
|
|
10
|
+
// Auth is a bearer token from SIGHTLINE_DEPLOY_TOKEN: a deploy token a tenant
|
|
11
|
+
// admin issued (`slxd_…`, sent to the same routes under /core/v1/deploy, the
|
|
12
|
+
// only place one is accepted), or an admin's own session token.
|
|
13
|
+
// Errors are `{ error: { code, message, ...details } }`.
|
|
14
|
+
export class CoreApiError extends Error {
|
|
15
|
+
status;
|
|
16
|
+
code;
|
|
17
|
+
details;
|
|
18
|
+
constructor(status, code, message, details = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.details = details;
|
|
23
|
+
this.name = "CoreApiError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function apiBase(options) {
|
|
27
|
+
const raw = options.apiUrl ?? (options.url ? `${options.url.replace(/\/+$/, "")}/functions/v1/core/v1` : undefined);
|
|
28
|
+
if (!raw)
|
|
29
|
+
throw new Error("name the workspace: --url https://<your sightline> (or SIGHTLINE_URL)");
|
|
30
|
+
let u;
|
|
31
|
+
try {
|
|
32
|
+
u = new URL(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error(`not a URL: ${raw}`);
|
|
36
|
+
}
|
|
37
|
+
// WHATWG URL keeps the brackets on an IPv6 hostname ("[::1]"); accept both spellings.
|
|
38
|
+
const local = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(u.hostname);
|
|
39
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && local)) {
|
|
40
|
+
throw new Error(`refusing to send a deploy token over ${u.protocol}//${u.host}; use https (http is allowed for localhost only)`);
|
|
41
|
+
}
|
|
42
|
+
if (u.username || u.password)
|
|
43
|
+
throw new Error("put the token in SIGHTLINE_DEPLOY_TOKEN, not in the URL");
|
|
44
|
+
return u.toString().replace(/\/+$/, "");
|
|
45
|
+
}
|
|
46
|
+
const toBase64 = (bytes) => {
|
|
47
|
+
let bin = "";
|
|
48
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
49
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
50
|
+
return btoa(bin);
|
|
51
|
+
};
|
|
52
|
+
export async function sha256Hex(bytes) {
|
|
53
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
54
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
55
|
+
}
|
|
56
|
+
/** A human sentence for an installer error, with the details that tell you what to do. */
|
|
57
|
+
export function describeError(err) {
|
|
58
|
+
const d = err.details;
|
|
59
|
+
switch (err.code) {
|
|
60
|
+
case "route_missing":
|
|
61
|
+
return err.message;
|
|
62
|
+
case "consent_required":
|
|
63
|
+
case "binding_required": {
|
|
64
|
+
const missing = (d.missing ?? d.permissions ?? []);
|
|
65
|
+
const names = missing.map((p) => (typeof p === "string" ? p : `${p.permission ?? p.key}${p.proposed ? ` (proposed: ${p.proposed})` : ""}`));
|
|
66
|
+
return `${err.message}${names.length ? `\n bind: ${names.join(", ")}` : ""}\n pass --bindings bindings.json ({ "<permission>": "<core permission you hold>" })`;
|
|
67
|
+
}
|
|
68
|
+
case "invalid_manifest": {
|
|
69
|
+
const errors = (d.errors ?? []);
|
|
70
|
+
return `${err.message}${errors.map((e) => `\n ${e.path ?? ""}: ${e.message ?? ""}`).join("")}`;
|
|
71
|
+
}
|
|
72
|
+
case "bundle_rejected":
|
|
73
|
+
case "ui_rejected": {
|
|
74
|
+
const errors = (d.errors ?? []);
|
|
75
|
+
return `${err.message}${errors.map((e) => `\n ${e}`).join("")}`;
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
return err.message;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** A deploy token an admin issued (`slxd_…`), as opposed to an admin's own session token. */
|
|
82
|
+
export function isDeployToken(token) {
|
|
83
|
+
return /^slxd_/i.test(token);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Where the lifecycle routes are for this token. A deploy token (`slxd_…`) is
|
|
87
|
+
* accepted only under `<core>/deploy` (the one hosted path CI reaches past the
|
|
88
|
+
* workspace's sign-in proxy); an admin's session token uses the admin routes.
|
|
89
|
+
*/
|
|
90
|
+
export function lifecycleBase(coreBase, token) {
|
|
91
|
+
return isDeployToken(token) ? `${coreBase}/deploy` : coreBase;
|
|
92
|
+
}
|
|
93
|
+
export function createCoreApi(options) {
|
|
94
|
+
if (!options.token || /\s/.test(options.token))
|
|
95
|
+
throw new Error("SIGHTLINE_DEPLOY_TOKEN is missing or malformed");
|
|
96
|
+
const base = lifecycleBase(apiBase(options), options.token);
|
|
97
|
+
const doFetch = options.fetch ?? fetch;
|
|
98
|
+
const timeoutMs = options.timeoutMs ?? 180_000;
|
|
99
|
+
async function call(method, path, body) {
|
|
100
|
+
const headers = { authorization: `Bearer ${options.token}`, accept: "application/json" };
|
|
101
|
+
if (options.apiKey)
|
|
102
|
+
headers.apikey = options.apiKey;
|
|
103
|
+
if (body !== undefined)
|
|
104
|
+
headers["content-type"] = "application/json";
|
|
105
|
+
let res;
|
|
106
|
+
try {
|
|
107
|
+
res = await doFetch(`${base}${path}`, {
|
|
108
|
+
method,
|
|
109
|
+
headers,
|
|
110
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
111
|
+
redirect: "error",
|
|
112
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
throw new Error(`cannot reach ${base}: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
let parsed = null;
|
|
120
|
+
try {
|
|
121
|
+
parsed = text ? JSON.parse(text) : null;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
parsed = null;
|
|
125
|
+
}
|
|
126
|
+
if (res.ok)
|
|
127
|
+
return parsed;
|
|
128
|
+
const error = (parsed && typeof parsed === "object" ? parsed.error : undefined);
|
|
129
|
+
if (error && typeof error.code === "string") {
|
|
130
|
+
const { code, message, ...details } = error;
|
|
131
|
+
throw new CoreApiError(res.status, code, typeof message === "string" ? message : code, details);
|
|
132
|
+
}
|
|
133
|
+
if (res.status === 401)
|
|
134
|
+
throw new CoreApiError(401, "unauthenticated", "the deploy token was refused (expired, revoked, or not a tenant admin's)");
|
|
135
|
+
if (res.status === 403)
|
|
136
|
+
throw new CoreApiError(403, "forbidden", "the deploy token may not manage extensions in this workspace");
|
|
137
|
+
if (res.status === 404) {
|
|
138
|
+
throw new CoreApiError(404, "route_missing", `${base}${path} does not exist: this Sightline has no hosted-extension installer yet, or --url is not a Sightline workspace`);
|
|
139
|
+
}
|
|
140
|
+
throw new CoreApiError(res.status, "http_error", `${method} ${path} answered HTTP ${res.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
base,
|
|
144
|
+
list: () => call("GET", "/extensions"),
|
|
145
|
+
async publish({ manifest, bundle, migrations, ui }) {
|
|
146
|
+
return await call("POST", "/extensions/releases", {
|
|
147
|
+
manifest,
|
|
148
|
+
bundle: { base64: toBase64(bundle), sha256: await sha256Hex(bundle) },
|
|
149
|
+
migrations,
|
|
150
|
+
...(ui && ui.length > 0
|
|
151
|
+
? { ui: { files: await Promise.all(ui.map(async (f) => ({ path: f.path, base64: toBase64(f.bytes), sha256: await sha256Hex(f.bytes) }))) } }
|
|
152
|
+
: {}),
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
async install(id, body) {
|
|
156
|
+
return (await call("POST", `/extensions/${encodeURIComponent(id)}/install`, body)).installation;
|
|
157
|
+
},
|
|
158
|
+
async upgrade(id, body) {
|
|
159
|
+
return (await call("POST", `/extensions/${encodeURIComponent(id)}/upgrade`, body)).installation;
|
|
160
|
+
},
|
|
161
|
+
async uninstall(id, body) {
|
|
162
|
+
const r = await call("POST", `/extensions/${encodeURIComponent(id)}/uninstall`, body);
|
|
163
|
+
return { installation: r.installation, warnings: r.warnings ?? [] };
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
package/dist/deploy.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CoreApi, InstallationView, PublishInput } from "./core-api.ts";
|
|
2
|
+
export type Out = (line: string) => void;
|
|
3
|
+
export declare function deploy(api: CoreApi, release: PublishInput, options: {
|
|
4
|
+
bindings?: Record<string, string>;
|
|
5
|
+
}, out: Out): Promise<InstallationView>;
|
|
6
|
+
export declare function upgrade(api: CoreApi, release: PublishInput, options: {
|
|
7
|
+
bindings?: Record<string, string>;
|
|
8
|
+
}, out: Out): Promise<InstallationView>;
|
|
9
|
+
export declare function uninstall(api: CoreApi, id: string, options: {
|
|
10
|
+
purge: boolean;
|
|
11
|
+
}, out: Out): Promise<InstallationView>;
|
|
12
|
+
/** --bindings <file>: { "<ext>.<permission>": "<core permission>" }. */
|
|
13
|
+
export declare function parseBindings(raw: unknown): Record<string, string>;
|
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** States in which an extension counts as installed (core keeps the row after uninstall). */
|
|
2
|
+
const LIVE = new Set(["enabled", "disabled", "provisioning"]);
|
|
3
|
+
async function current(api, id) {
|
|
4
|
+
const { installations } = await api.list();
|
|
5
|
+
return installations.find((i) => i.extensionId === id && LIVE.has(i.state)) ?? null;
|
|
6
|
+
}
|
|
7
|
+
async function publish(api, release, out) {
|
|
8
|
+
const id = release.manifest.id;
|
|
9
|
+
const version = release.manifest.version;
|
|
10
|
+
const { release: published, created } = await api.publish(release);
|
|
11
|
+
out(created ? `published ${id}@${version} (bundle sha256 ${published.bundleSha256.slice(0, 12)}…)` : `${id}@${version} is already published with these exact bytes`);
|
|
12
|
+
const ui = published.checks?.ui;
|
|
13
|
+
if (created && ui)
|
|
14
|
+
out(` uploaded the UI: ${ui.files} files, ${ui.bytes} bytes (sha256 ${ui.sha256.slice(0, 12)}…)`);
|
|
15
|
+
for (const w of published.checks?.warnings ?? [])
|
|
16
|
+
out(` warning: ${w}`);
|
|
17
|
+
if (published.coreApiCompatible === false)
|
|
18
|
+
out(` warning: this workspace's core does not satisfy coreApi ${String(release.manifest.coreApi)}; install will be refused`);
|
|
19
|
+
}
|
|
20
|
+
export async function deploy(api, release, options, out) {
|
|
21
|
+
const id = release.manifest.id;
|
|
22
|
+
const version = release.manifest.version;
|
|
23
|
+
const existing = await current(api, id);
|
|
24
|
+
if (existing && existing.version !== version) {
|
|
25
|
+
throw new Error(`${id} is already installed at ${existing.version}; run \`sightline-ext upgrade\` to move it to ${version}`);
|
|
26
|
+
}
|
|
27
|
+
await publish(api, release, out);
|
|
28
|
+
if (existing) {
|
|
29
|
+
out(`${id}@${version} is already installed (${existing.state}); nothing to do`);
|
|
30
|
+
return existing;
|
|
31
|
+
}
|
|
32
|
+
const installation = await api.install(id, { version, ...(options.bindings ? { bindings: options.bindings } : {}) });
|
|
33
|
+
out(`installed ${id}@${installation.version}: ${installation.state} (installation ${installation.installationId})`);
|
|
34
|
+
return installation;
|
|
35
|
+
}
|
|
36
|
+
export async function upgrade(api, release, options, out) {
|
|
37
|
+
const id = release.manifest.id;
|
|
38
|
+
const version = release.manifest.version;
|
|
39
|
+
const existing = await current(api, id);
|
|
40
|
+
if (!existing)
|
|
41
|
+
throw new Error(`${id} is not installed in this workspace; run \`sightline-ext deploy\` first`);
|
|
42
|
+
await publish(api, release, out);
|
|
43
|
+
if (existing.version === version) {
|
|
44
|
+
out(`${id} already runs ${version}; nothing to upgrade (bump "version" in the manifest to release a change)`);
|
|
45
|
+
return existing;
|
|
46
|
+
}
|
|
47
|
+
const installation = await api.upgrade(id, { version, ...(options.bindings ? { bindings: options.bindings } : {}) });
|
|
48
|
+
out(`upgraded ${id} ${existing.version} → ${installation.version}: ${installation.state}`);
|
|
49
|
+
return installation;
|
|
50
|
+
}
|
|
51
|
+
export async function uninstall(api, id, options, out) {
|
|
52
|
+
const { installation, warnings } = await api.uninstall(id, { purge: options.purge });
|
|
53
|
+
out(options.purge
|
|
54
|
+
? `uninstalled ${id} and dropped its data`
|
|
55
|
+
: `uninstalled ${id}; its data is kept${installation.purgeAfter ? ` until ${String(installation.purgeAfter)}` : " for 30 days"} (\`--purge\` drops it now)`);
|
|
56
|
+
for (const w of warnings)
|
|
57
|
+
out(` warning: ${w}`);
|
|
58
|
+
return installation;
|
|
59
|
+
}
|
|
60
|
+
/** --bindings <file>: { "<ext>.<permission>": "<core permission>" }. */
|
|
61
|
+
export function parseBindings(raw) {
|
|
62
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
63
|
+
throw new Error("bindings must be a JSON object of permission → core permission");
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
66
|
+
if (typeof v !== "string" || v === "")
|
|
67
|
+
throw new Error(`the binding for ${k} must be a core permission name`);
|
|
68
|
+
out[k] = v;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { main, packageVersion, USAGE } from "./cli.ts";
|
|
2
|
+
export { bool, type Flags, parseArgs, type ParsedArgs, str } from "./args.ts";
|
|
3
|
+
export { build, type BuildDeps, type BuildResult, type Bundler, type BundleLinter, bundleOptions, type BundleOptions, collectUi, manifestProblems, type ManifestValidator, type UiValidator } from "./build.ts";
|
|
4
|
+
export { apiBase, type CoreApi, CoreApiError, type CoreApiOptions, createCoreApi, describeError, type InstallationView, isDeployToken, lifecycleBase, type PublishInput, type ReleaseView, sha256Hex } from "./core-api.ts";
|
|
5
|
+
export { deploy, parseBindings, uninstall, upgrade } from "./deploy.ts";
|
|
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
|
+
export { lintBundle, MAX_BUNDLE_BYTES } from "@sparelabs/sightline-extension-manifest";
|
|
8
|
+
export { templateFiles, type TemplateOptions, templateProblems, toolPrefix } from "./template.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @sparelabs/sightline-extension-cli: the `sightline-ext` command, and its parts
|
|
2
|
+
// for scripts that want them. This package imports nothing from the app
|
|
3
|
+
// (extension-sdk/test/boundary.test.ts).
|
|
4
|
+
export { main, packageVersion, USAGE } from "./cli.js";
|
|
5
|
+
export { bool, parseArgs, str } from "./args.js";
|
|
6
|
+
export { build, bundleOptions, collectUi, manifestProblems } from "./build.js";
|
|
7
|
+
export { apiBase, CoreApiError, createCoreApi, describeError, isDeployToken, lifecycleBase, sha256Hex } from "./core-api.js";
|
|
8
|
+
export { deploy, parseBindings, uninstall, upgrade } from "./deploy.js";
|
|
9
|
+
export { CONFIG_FILE, defaultContract, hostedRuleProblems, loadProject, MANIFEST_FILE, migrationProblems, readMigrations, readUiFiles, uiFrames, } from "./project.js";
|
|
10
|
+
// The bundle lint is the published contract's, the same function core's installer runs.
|
|
11
|
+
export { lintBundle, MAX_BUNDLE_BYTES } from "@sparelabs/sightline-extension-manifest";
|
|
12
|
+
export { templateFiles, templateProblems, toolPrefix } from "./template.js";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** The file reads a project needs; the tests pass an in-memory one. */
|
|
2
|
+
export interface ProjectFs {
|
|
3
|
+
readText(path: string): string;
|
|
4
|
+
readBytes(path: string): Uint8Array;
|
|
5
|
+
exists(path: string): boolean;
|
|
6
|
+
isDirectory(path: string): boolean;
|
|
7
|
+
list(dir: string): string[];
|
|
8
|
+
}
|
|
9
|
+
export declare const nodeFs: ProjectFs;
|
|
10
|
+
export declare const MANIFEST_FILE = "sightline.extension.json";
|
|
11
|
+
export declare const CONFIG_FILE = "sightline-ext.json";
|
|
12
|
+
export declare const DEFAULT_ENTRY = "server/main.ts";
|
|
13
|
+
export declare const DEFAULT_OUTFILE = "dist/server.mjs";
|
|
14
|
+
export declare const DEFAULT_MIGRATIONS = "migrations";
|
|
15
|
+
/** Where the extension's UI build is read from when the manifest declares ui.frames. */
|
|
16
|
+
export declare const DEFAULT_UI_DIR = "dist/ui";
|
|
17
|
+
/** Core's publish limits (installer.ts) on migrations. The bundle's limit and lint come from the manifest package. */
|
|
18
|
+
export declare const MAX_MIGRATION_BYTES: number;
|
|
19
|
+
export declare const MAX_MIGRATIONS = 200;
|
|
20
|
+
export declare const EXT_ID_RE: RegExp;
|
|
21
|
+
export declare const MIGRATION_FILE_RE: RegExp;
|
|
22
|
+
export interface ContractConfig {
|
|
23
|
+
tool?: {
|
|
24
|
+
name: string;
|
|
25
|
+
input?: Record<string, unknown>;
|
|
26
|
+
as?: string;
|
|
27
|
+
};
|
|
28
|
+
job?: {
|
|
29
|
+
name: string;
|
|
30
|
+
payload?: unknown;
|
|
31
|
+
};
|
|
32
|
+
hook?: {
|
|
33
|
+
name: string;
|
|
34
|
+
body?: unknown;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** sightline-ext.json: how `dev` and `test` run the extension locally. Never deployed. */
|
|
38
|
+
export interface ProjectConfig {
|
|
39
|
+
/** The server entry (default server/main.ts). */
|
|
40
|
+
entry?: string;
|
|
41
|
+
/** What `test` checks: a tool that succeeds, and optionally a job and a hook. */
|
|
42
|
+
contract?: ContractConfig;
|
|
43
|
+
/** The installation settings the emulated core answers with. */
|
|
44
|
+
settings?: Record<string, unknown>;
|
|
45
|
+
/** Development-only secret values, injected as SL_SECRET_<NAME>. Never deployed. */
|
|
46
|
+
devSecrets?: Record<string, string>;
|
|
47
|
+
/** The built UI `deploy` uploads with the release, for ui.frames (default dist/ui; build it with your own tool first). */
|
|
48
|
+
ui?: {
|
|
49
|
+
dir?: string;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export interface UiFileOnDisk {
|
|
53
|
+
/** Relative to the UI directory, `/`-separated. */
|
|
54
|
+
path: string;
|
|
55
|
+
bytes: Uint8Array;
|
|
56
|
+
}
|
|
57
|
+
export interface MigrationFile {
|
|
58
|
+
file: string;
|
|
59
|
+
sql: string;
|
|
60
|
+
}
|
|
61
|
+
export interface Project {
|
|
62
|
+
dir: string;
|
|
63
|
+
manifestPath: string;
|
|
64
|
+
manifest: Record<string, unknown>;
|
|
65
|
+
id: string;
|
|
66
|
+
version: string;
|
|
67
|
+
entry: string;
|
|
68
|
+
outfile: string;
|
|
69
|
+
migrationsDir: string;
|
|
70
|
+
uiDir: string;
|
|
71
|
+
config: ProjectConfig;
|
|
72
|
+
fs: ProjectFs;
|
|
73
|
+
}
|
|
74
|
+
export declare function loadProject(dir: string, options?: {
|
|
75
|
+
manifest?: string;
|
|
76
|
+
config?: string;
|
|
77
|
+
entry?: string;
|
|
78
|
+
}, fs?: ProjectFs): Project;
|
|
79
|
+
/**
|
|
80
|
+
* Problems core's publish would refuse that the manifest schema alone does
|
|
81
|
+
* not catch: a hosted, T2 extension, a kebab-case id, a plain MAJOR.MINOR.PATCH
|
|
82
|
+
* version, and a coreApi range.
|
|
83
|
+
*/
|
|
84
|
+
export declare function hostedRuleProblems(manifest: Record<string, unknown>): string[];
|
|
85
|
+
/** The manifest's `ui.frames`, when it declares an iframe UI. */
|
|
86
|
+
export declare function uiFrames(manifest: Record<string, unknown>): {
|
|
87
|
+
entry: string;
|
|
88
|
+
} | null;
|
|
89
|
+
/**
|
|
90
|
+
* Every file under the UI build directory, with `/`-separated relative paths,
|
|
91
|
+
* sorted. Hidden files and directories (`.DS_Store`, `.vite/`) are skipped:
|
|
92
|
+
* they are never served.
|
|
93
|
+
*/
|
|
94
|
+
export declare function readUiFiles(dir: string, fs?: ProjectFs): UiFileOnDisk[];
|
|
95
|
+
/** The migrations directory's .sql files, checked the way core's publish checks them. */
|
|
96
|
+
export declare function readMigrations(dir: string, fs?: ProjectFs): MigrationFile[];
|
|
97
|
+
export declare function migrationProblems(files: MigrationFile[]): string[];
|
|
98
|
+
/** The tool `test` calls when sightline-ext.json names none: the manifest's first, as user:alice. */
|
|
99
|
+
export declare function defaultContract(project: Project): Required<Pick<ContractConfig, "tool">> & ContractConfig;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// An extension project on disk: the manifest, the server entry and bundle path,
|
|
2
|
+
// the migrations, and the optional sightline-ext.json. The checks here mirror
|
|
3
|
+
// what core's installer refuses at publish (docs/platform/hosted-extensions-registry.md
|
|
4
|
+
// → "The rules"), so a developer sees the problem at build time, not at deploy.
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
export const nodeFs = {
|
|
8
|
+
readText: (path) => readFileSync(path, "utf8"),
|
|
9
|
+
readBytes: (path) => new Uint8Array(readFileSync(path)),
|
|
10
|
+
exists: (path) => existsSync(path),
|
|
11
|
+
isDirectory: (path) => existsSync(path) && statSync(path).isDirectory(),
|
|
12
|
+
list: (dir) => readdirSync(dir),
|
|
13
|
+
};
|
|
14
|
+
export const MANIFEST_FILE = "sightline.extension.json";
|
|
15
|
+
export const CONFIG_FILE = "sightline-ext.json";
|
|
16
|
+
export const DEFAULT_ENTRY = "server/main.ts";
|
|
17
|
+
export const DEFAULT_OUTFILE = "dist/server.mjs";
|
|
18
|
+
export const DEFAULT_MIGRATIONS = "migrations";
|
|
19
|
+
/** Where the extension's UI build is read from when the manifest declares ui.frames. */
|
|
20
|
+
export const DEFAULT_UI_DIR = "dist/ui";
|
|
21
|
+
/** Core's publish limits (installer.ts) on migrations. The bundle's limit and lint come from the manifest package. */
|
|
22
|
+
export const MAX_MIGRATION_BYTES = 256 * 1024;
|
|
23
|
+
export const MAX_MIGRATIONS = 200;
|
|
24
|
+
export const EXT_ID_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
25
|
+
export const MIGRATION_FILE_RE = /^(\d{4})_([a-z0-9][a-z0-9_]{0,47})\.sql$/;
|
|
26
|
+
const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
27
|
+
function readJson(fs, path, what) {
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = fs.readText(path);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
throw new Error(`cannot read ${what} ${path}: ${e.message}`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
throw new Error(`${what} ${path} is not valid JSON: ${e.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function loadProject(dir, options = {}, fs = nodeFs) {
|
|
43
|
+
const root = resolve(dir);
|
|
44
|
+
const manifestPath = resolve(root, options.manifest ?? MANIFEST_FILE);
|
|
45
|
+
const manifest = readJson(fs, manifestPath, "the manifest");
|
|
46
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
|
|
47
|
+
throw new Error(`${manifestPath} must be a JSON object`);
|
|
48
|
+
const m = manifest;
|
|
49
|
+
const configPath = resolve(root, options.config ?? CONFIG_FILE);
|
|
50
|
+
const config = (fs.exists(configPath) ? readJson(fs, configPath, "the config") : {});
|
|
51
|
+
const runtime = (m.runtime ?? {});
|
|
52
|
+
return {
|
|
53
|
+
dir: root,
|
|
54
|
+
manifestPath,
|
|
55
|
+
manifest: m,
|
|
56
|
+
id: typeof m.id === "string" ? m.id : "",
|
|
57
|
+
version: typeof m.version === "string" ? m.version : "",
|
|
58
|
+
entry: resolve(root, options.entry ?? config.entry ?? DEFAULT_ENTRY),
|
|
59
|
+
outfile: resolve(root, typeof runtime.entry === "string" ? runtime.entry : DEFAULT_OUTFILE),
|
|
60
|
+
migrationsDir: join(root, DEFAULT_MIGRATIONS),
|
|
61
|
+
uiDir: resolve(root, typeof config.ui?.dir === "string" ? config.ui.dir : DEFAULT_UI_DIR),
|
|
62
|
+
config,
|
|
63
|
+
fs,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Problems core's publish would refuse that the manifest schema alone does
|
|
68
|
+
* not catch: a hosted, T2 extension, a kebab-case id, a plain MAJOR.MINOR.PATCH
|
|
69
|
+
* version, and a coreApi range.
|
|
70
|
+
*/
|
|
71
|
+
export function hostedRuleProblems(manifest) {
|
|
72
|
+
const problems = [];
|
|
73
|
+
const runtime = (manifest.runtime ?? {});
|
|
74
|
+
if (manifest.trust !== "T2" || runtime.kind !== "hosted")
|
|
75
|
+
problems.push('a hosted extension needs "trust": "T2" and "runtime": { "kind": "hosted", … }');
|
|
76
|
+
const id = manifest.id;
|
|
77
|
+
if (typeof id !== "string" || !EXT_ID_RE.test(id) || id.length > 40)
|
|
78
|
+
problems.push("id must be kebab-case, at most 40 characters");
|
|
79
|
+
if (typeof manifest.version !== "string" || !VERSION_RE.test(manifest.version))
|
|
80
|
+
problems.push("version must be MAJOR.MINOR.PATCH (no pre-release)");
|
|
81
|
+
if (typeof manifest.coreApi !== "string" || manifest.coreApi.trim() === "")
|
|
82
|
+
problems.push('coreApi must name the core API range you build against, e.g. "^0.1"');
|
|
83
|
+
return problems;
|
|
84
|
+
}
|
|
85
|
+
/** The manifest's `ui.frames`, when it declares an iframe UI. */
|
|
86
|
+
export function uiFrames(manifest) {
|
|
87
|
+
const frames = (manifest.ui ?? {}).frames;
|
|
88
|
+
if (!frames || typeof frames !== "object")
|
|
89
|
+
return null;
|
|
90
|
+
return { entry: typeof frames.entry === "string" && frames.entry !== "" ? frames.entry : "index.html" };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Every file under the UI build directory, with `/`-separated relative paths,
|
|
94
|
+
* sorted. Hidden files and directories (`.DS_Store`, `.vite/`) are skipped:
|
|
95
|
+
* they are never served.
|
|
96
|
+
*/
|
|
97
|
+
export function readUiFiles(dir, fs = nodeFs) {
|
|
98
|
+
const out = [];
|
|
99
|
+
const walk = (abs, rel) => {
|
|
100
|
+
for (const name of fs.list(abs).sort()) {
|
|
101
|
+
if (name.startsWith("."))
|
|
102
|
+
continue;
|
|
103
|
+
const childAbs = join(abs, name);
|
|
104
|
+
const childRel = rel ? `${rel}/${name}` : name;
|
|
105
|
+
if (fs.isDirectory(childAbs))
|
|
106
|
+
walk(childAbs, childRel);
|
|
107
|
+
else
|
|
108
|
+
out.push({ path: childRel, bytes: fs.readBytes(childAbs) });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(dir, "");
|
|
112
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
113
|
+
}
|
|
114
|
+
/** The migrations directory's .sql files, checked the way core's publish checks them. */
|
|
115
|
+
export function readMigrations(dir, fs = nodeFs) {
|
|
116
|
+
if (!fs.isDirectory(dir))
|
|
117
|
+
return [];
|
|
118
|
+
const files = fs.list(dir).filter((f) => !f.startsWith(".")).sort();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
if (!file.endsWith(".sql"))
|
|
122
|
+
throw new Error(`migrations/${file}: only NNNN_<name>.sql files belong in migrations/`);
|
|
123
|
+
out.push({ file, sql: fs.readText(join(dir, file)) });
|
|
124
|
+
}
|
|
125
|
+
const problems = migrationProblems(out);
|
|
126
|
+
if (problems.length)
|
|
127
|
+
throw new Error(`migrations: ${problems.join("; ")}`);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
export function migrationProblems(files) {
|
|
131
|
+
const problems = [];
|
|
132
|
+
if (files.length > MAX_MIGRATIONS)
|
|
133
|
+
problems.push(`at most ${MAX_MIGRATIONS} migrations`);
|
|
134
|
+
const versions = [];
|
|
135
|
+
for (const { file, sql } of files) {
|
|
136
|
+
const m = MIGRATION_FILE_RE.exec(file);
|
|
137
|
+
if (!m) {
|
|
138
|
+
problems.push(`${file}: file names are NNNN_<name>.sql (lowercase name, at most 48 characters)`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (sql.trim() === "")
|
|
142
|
+
problems.push(`${file} is empty`);
|
|
143
|
+
if (sql.includes("\u0000"))
|
|
144
|
+
problems.push(`${file} contains a NUL character`);
|
|
145
|
+
if (new TextEncoder().encode(sql).length > MAX_MIGRATION_BYTES)
|
|
146
|
+
problems.push(`${file} is over ${MAX_MIGRATION_BYTES} bytes`);
|
|
147
|
+
versions.push(Number(m[1]));
|
|
148
|
+
}
|
|
149
|
+
versions.sort((a, b) => a - b).forEach((v, i) => {
|
|
150
|
+
if (v !== i + 1 && problems.length === 0)
|
|
151
|
+
problems.push(`migrations are numbered from 0001 with no gaps (expected ${String(i + 1).padStart(4, "0")}, found ${String(v).padStart(4, "0")})`);
|
|
152
|
+
});
|
|
153
|
+
return problems;
|
|
154
|
+
}
|
|
155
|
+
/** The tool `test` calls when sightline-ext.json names none: the manifest's first, as user:alice. */
|
|
156
|
+
export function defaultContract(project) {
|
|
157
|
+
const configured = project.config.contract ?? {};
|
|
158
|
+
if (configured.tool)
|
|
159
|
+
return { ...configured, tool: configured.tool };
|
|
160
|
+
const tools = Array.isArray(project.manifest.tools) ? project.manifest.tools : [];
|
|
161
|
+
const first = tools.find((t) => typeof t?.name === "string");
|
|
162
|
+
if (!first)
|
|
163
|
+
throw new Error(`the manifest declares no tools; name one under "contract.tool" in ${CONFIG_FILE}`);
|
|
164
|
+
return { ...configured, tool: { name: first.name, input: {}, as: "user:alice" } };
|
|
165
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface TemplateOptions {
|
|
2
|
+
id: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
publisher?: string;
|
|
5
|
+
/** The published packages' version (the CLI's own); dependencies use ^<version>. */
|
|
6
|
+
packagesVersion: string;
|
|
7
|
+
}
|
|
8
|
+
/** kebab-case id → the snake_case prefix of its tool names. */
|
|
9
|
+
export declare const toolPrefix: (id: string) => string;
|
|
10
|
+
export declare function templateProblems(options: TemplateOptions): string[];
|
|
11
|
+
export declare function templateFiles(options: TemplateOptions): Record<string, string>;
|