@mediamonks/canopy-cli 0.7.11 → 0.7.12
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/dist/{deploy-BCtEY1is.mjs → deploy-B1WrPq5W.mjs} +8 -1
- package/dist/{dev-DtoZrexp.mjs → dev-BnUYGQQm.mjs} +5 -4
- package/dist/dev-CySBxW7I.mjs +2 -0
- package/dist/{dev-worker-BZgtEM9V.mjs → dev-worker-IWnHvQ1f.mjs} +42 -7
- package/dist/{docs-BV9qhLJx.mjs → docs-DxB7NhZs.mjs} +1 -1
- package/dist/{feedback-Cb8ccQ_R.mjs → feedback-B-GXUBPs.mjs} +1 -1
- package/dist/index.mjs +10 -10
- package/dist/{local-C5AhA4GM.mjs → local-BGRd_FMZ.mjs} +6 -469
- package/dist/{package-BBpw-n7k.mjs → package-CDLufecy.mjs} +1 -1
- package/dist/{skill-DJHlTEhS.mjs → skill-BOWfqwr2.mjs} +1 -1
- package/dist/src-Bu4Ze32I.mjs +470 -0
- package/dist/{upgrade-B86Ao6T5.mjs → upgrade-fhfrid_O.mjs} +1 -1
- package/package.json +3 -3
- package/dist/dev-BYxSHUH6.mjs +0 -2
- package/dist/docs/ci.md +0 -50
- package/dist/docs/cli-create.md +0 -46
- package/dist/docs/cli-db.md +0 -86
- package/dist/docs/cli-delete.md +0 -44
- package/dist/docs/cli-deploy.md +0 -58
- package/dist/docs/cli-dev.md +0 -82
- package/dist/docs/cli-docs.md +0 -47
- package/dist/docs/cli-download.md +0 -42
- package/dist/docs/cli-egress.md +0 -37
- package/dist/docs/cli-feedback.md +0 -75
- package/dist/docs/cli-index.md +0 -61
- package/dist/docs/cli-init.md +0 -46
- package/dist/docs/cli-login.md +0 -43
- package/dist/docs/cli-logout.md +0 -32
- package/dist/docs/cli-logs.md +0 -57
- package/dist/docs/cli-open.md +0 -41
- package/dist/docs/cli-projects.md +0 -43
- package/dist/docs/cli-promote.md +0 -44
- package/dist/docs/cli-rollback.md +0 -45
- package/dist/docs/cli-skill.md +0 -39
- package/dist/docs/cli-staging.md +0 -54
- package/dist/docs/cli-token.md +0 -47
- package/dist/docs/cli-upgrade.md +0 -43
- package/dist/docs/cli-usage.md +0 -43
- package/dist/docs/cli-whoami.md +0 -46
- package/dist/docs/cli.md +0 -86
- package/dist/docs/data.md +0 -85
- package/dist/docs/deploy.md +0 -81
- package/dist/docs/dev.md +0 -72
- package/dist/docs/developers-agents-and-ai.md +0 -95
- package/dist/docs/developers-architecture.md +0 -106
- package/dist/docs/getting-started.md +0 -108
- package/dist/docs/index.json +0 -331
- package/dist/docs/infrastructure.md +0 -406
- package/dist/docs/migrations.md +0 -183
- package/dist/docs/overview-faq.md +0 -105
- package/dist/docs/overview-how-it-works.md +0 -82
- package/dist/docs/overview-use-cases.md +0 -76
- package/dist/docs/overview-what-is-canopy.md +0 -75
- package/dist/docs/platform-ai-usage.md +0 -65
- package/dist/docs/platform-client-access.md +0 -54
- package/dist/docs/platform-deploys-and-rollbacks.md +0 -93
- package/dist/docs/platform-egress.md +0 -75
- package/dist/docs/platform-feedback.md +0 -114
- package/dist/docs/platform-members-and-access.md +0 -71
- package/dist/docs/platform-project-data.md +0 -76
- package/dist/docs/platform-schedules.md +0 -90
- package/dist/docs/platform-sites-and-profiles.md +0 -61
- package/dist/docs/porting-nextjs.md +0 -233
- package/dist/docs/sdk-ai.md +0 -103
- package/dist/docs/sdk-auth.md +0 -57
- package/dist/docs/sdk-database.md +0 -126
- package/dist/docs/sdk-files.md +0 -82
- package/dist/docs/sdk-services.md +0 -58
- package/dist/docs/sdk.md +0 -126
- package/dist/docs/staging.md +0 -106
- package/dist/docs/static-serving.md +0 -318
- package/dist/docs/troubleshooting.md +0 -102
- package/dist/skill/canopy/SKILL.md +0 -160
- package/dist/templates/spa/.github/workflows/canopy-deploy.yml +0 -34
- package/dist/templates/spa/AGENTS.md +0 -47
- package/dist/templates/spa/README.md +0 -14
- package/dist/templates/spa/_gitignore +0 -4
- package/dist/templates/spa/canopy.json +0 -5
- package/dist/templates/spa/index.html +0 -12
- package/dist/templates/spa/package.json +0 -21
- package/dist/templates/spa/src/app.tsx +0 -25
- package/dist/templates/spa/src/canopy.ts +0 -5
- package/dist/templates/spa/src/main.tsx +0 -10
- package/dist/templates/spa/src/style.css +0 -29
- package/dist/templates/spa/tsconfig.json +0 -16
- package/dist/templates/spa/vite.config.ts +0 -7
- package/dist/templates/static/AGENTS.md +0 -45
- package/dist/templates/static/README.md +0 -9
- package/dist/templates/static/_gitignore +0 -2
- package/dist/templates/static/canopy.json +0 -4
- package/dist/templates/static/index.html +0 -21
- package/dist/templates/static/main.js +0 -9
- package/dist/templates/static/style.css +0 -29
- package/dist/templates/worker/.dev.vars.example +0 -9
- package/dist/templates/worker/.github/workflows/canopy-deploy.yml +0 -34
- package/dist/templates/worker/AGENTS.md +0 -63
- package/dist/templates/worker/README.md +0 -17
- package/dist/templates/worker/_gitignore +0 -5
- package/dist/templates/worker/canopy.json +0 -6
- package/dist/templates/worker/index.html +0 -12
- package/dist/templates/worker/package.json +0 -22
- package/dist/templates/worker/src/app.tsx +0 -25
- package/dist/templates/worker/src/canopy.ts +0 -5
- package/dist/templates/worker/src/main.tsx +0 -10
- package/dist/templates/worker/src/style.css +0 -29
- package/dist/templates/worker/tsconfig.json +0 -16
- package/dist/templates/worker/vite.config.ts +0 -7
- package/dist/templates/worker/worker/index.ts +0 -52
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
1
|
+
import { t as version } from "./package-CDLufecy.mjs";
|
|
2
2
|
import { n as reportErrors } from "./ui-2mrJcjYC.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
//#region ../data-plane/src/blob.ts
|
|
3
|
+
/**
|
|
4
|
+
* /blob — the SDK's file storage over an injectable BlobStore (a bucket
|
|
5
|
+
* plus the project's isolated key prefix). Same single-implementation deal as
|
|
6
|
+
* db.ts: canopy-api and the CLI's local sandbox mount this exact router.
|
|
7
|
+
*
|
|
8
|
+
* Authorization is the HOST's job (canopy-api enforces session-or-signature
|
|
9
|
+
* ahead of this router; the local sandbox has no auth wall). This router only
|
|
10
|
+
* mints download URLs via the injected signer — verification lives with the
|
|
11
|
+
* host's auth middleware (see signing.ts).
|
|
12
|
+
*/
|
|
13
|
+
const MAX_BLOB_BYTES = 25 * 1024 * 1024;
|
|
14
|
+
const MAX_LIST_KEYS = 1e3;
|
|
15
|
+
const URL_TTL_SECONDS = 600;
|
|
16
|
+
/** Keys may nest with `/`, but never escape the prefix or surprise a URL. */
|
|
17
|
+
function safeBlobKey(raw) {
|
|
18
|
+
if (raw.length === 0 || raw.length > 512) return null;
|
|
19
|
+
if (raw.includes("\\") || raw.includes("\0")) return null;
|
|
20
|
+
if (raw.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) return null;
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
function blobRouter(config) {
|
|
24
|
+
const blob = new Hono();
|
|
25
|
+
const resolve = async (c) => config.store(c);
|
|
26
|
+
blob.put("/blob/:key", async (c) => {
|
|
27
|
+
const resolved = await resolve(c);
|
|
28
|
+
if (resolved instanceof Response) return resolved;
|
|
29
|
+
const { store } = resolved;
|
|
30
|
+
const key = safeBlobKey(c.req.param("key"));
|
|
31
|
+
if (!key) return c.json({ error: "invalid_key" }, 400);
|
|
32
|
+
if (Number(c.req.header("content-length") ?? 0) > MAX_BLOB_BYTES) return c.json({
|
|
33
|
+
error: "blob_too_large",
|
|
34
|
+
maxBytes: MAX_BLOB_BYTES
|
|
35
|
+
}, 413);
|
|
36
|
+
const body = await c.req.raw.arrayBuffer();
|
|
37
|
+
if (body.byteLength > MAX_BLOB_BYTES) return c.json({
|
|
38
|
+
error: "blob_too_large",
|
|
39
|
+
maxBytes: MAX_BLOB_BYTES
|
|
40
|
+
}, 413);
|
|
41
|
+
const contentType = c.req.header("content-type") ?? "application/octet-stream";
|
|
42
|
+
await store.bucket.put(store.prefix + key, body, { httpMetadata: { contentType } });
|
|
43
|
+
return c.json({
|
|
44
|
+
key,
|
|
45
|
+
size: body.byteLength,
|
|
46
|
+
contentType,
|
|
47
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
48
|
+
}, 201);
|
|
49
|
+
});
|
|
50
|
+
blob.get("/blob", async (c) => {
|
|
51
|
+
const resolved = await resolve(c);
|
|
52
|
+
if (resolved instanceof Response) return resolved;
|
|
53
|
+
const { store } = resolved;
|
|
54
|
+
const prefix = c.req.query("prefix") ?? "";
|
|
55
|
+
if (prefix.length > 512 || prefix.includes("\0")) return c.json({ error: "invalid_prefix" }, 400);
|
|
56
|
+
const base = store.prefix;
|
|
57
|
+
const objects = [];
|
|
58
|
+
let cursor;
|
|
59
|
+
do {
|
|
60
|
+
const page = await store.bucket.list({
|
|
61
|
+
prefix: base + prefix,
|
|
62
|
+
cursor,
|
|
63
|
+
include: ["httpMetadata"]
|
|
64
|
+
});
|
|
65
|
+
for (const o of page.objects) objects.push({
|
|
66
|
+
key: o.key.slice(base.length),
|
|
67
|
+
size: o.size,
|
|
68
|
+
contentType: o.httpMetadata?.contentType ?? "application/octet-stream",
|
|
69
|
+
uploadedAt: o.uploaded.toISOString()
|
|
70
|
+
});
|
|
71
|
+
cursor = page.truncated && objects.length < MAX_LIST_KEYS ? page.cursor : void 0;
|
|
72
|
+
} while (cursor);
|
|
73
|
+
return c.json(objects.slice(0, MAX_LIST_KEYS));
|
|
74
|
+
});
|
|
75
|
+
blob.get("/blob/:key/url", async (c) => {
|
|
76
|
+
const resolved = await resolve(c);
|
|
77
|
+
if (resolved instanceof Response) return resolved;
|
|
78
|
+
const { slug, store } = resolved;
|
|
79
|
+
const key = safeBlobKey(c.req.param("key"));
|
|
80
|
+
if (!key) return c.json({ error: "invalid_key" }, 400);
|
|
81
|
+
if (!await store.bucket.head(store.prefix + key)) return c.json({ error: "not_found" }, 404);
|
|
82
|
+
const exp = Math.floor(Date.now() / 1e3) + URL_TTL_SECONDS;
|
|
83
|
+
const sig = await config.signDownload(c, slug, key, exp);
|
|
84
|
+
return c.json({
|
|
85
|
+
url: `/_api/data/v1/blob/${encodeURIComponent(key)}?project=${encodeURIComponent(slug)}&exp=${exp}&sig=${sig}`,
|
|
86
|
+
expiresAt: (/* @__PURE__ */ new Date(exp * 1e3)).toISOString()
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
blob.get("/blob/:key", async (c) => {
|
|
90
|
+
const resolved = await resolve(c);
|
|
91
|
+
if (resolved instanceof Response) return resolved;
|
|
92
|
+
const { store } = resolved;
|
|
93
|
+
const key = safeBlobKey(c.req.param("key"));
|
|
94
|
+
if (!key) return c.json({ error: "invalid_key" }, 400);
|
|
95
|
+
const object = await store.bucket.get(store.prefix + key);
|
|
96
|
+
if (!object) return c.json({ error: "not_found" }, 404);
|
|
97
|
+
return new Response(object.body, { headers: {
|
|
98
|
+
"content-type": object.httpMetadata?.contentType ?? "application/octet-stream",
|
|
99
|
+
"cache-control": "private, no-store"
|
|
100
|
+
} });
|
|
101
|
+
});
|
|
102
|
+
blob.delete("/blob/:key", async (c) => {
|
|
103
|
+
const resolved = await resolve(c);
|
|
104
|
+
if (resolved instanceof Response) return resolved;
|
|
105
|
+
const { store } = resolved;
|
|
106
|
+
const key = safeBlobKey(c.req.param("key"));
|
|
107
|
+
if (!key) return c.json({ error: "invalid_key" }, 400);
|
|
108
|
+
const fullKey = store.prefix + key;
|
|
109
|
+
const existed = await store.bucket.head(fullKey) !== null;
|
|
110
|
+
await store.bucket.delete(fullKey);
|
|
111
|
+
return c.json({ deleted: existed });
|
|
112
|
+
});
|
|
113
|
+
return blob;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region ../data-plane/src/dialect.ts
|
|
117
|
+
/** Platform-managed fields map to real columns instead of JSON extraction. */
|
|
118
|
+
const META_COLUMNS = {
|
|
119
|
+
_id: "id",
|
|
120
|
+
_createdAt: "created_at",
|
|
121
|
+
_updatedAt: "updated_at"
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region ../data-plane/src/schema.ts
|
|
125
|
+
/** Platform/system tables never shown to users. */
|
|
126
|
+
const RESERVED = /^(_canopy_|_cf_|sqlite_|pg_)/;
|
|
127
|
+
/** Single-quoted SQL string literal (introspection args ride inline, not bound). */
|
|
128
|
+
const lit = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
129
|
+
async function sqliteSchema(store) {
|
|
130
|
+
const names = (await store.query(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)).results.map((r) => String(r.name)).filter((n) => !RESERVED.test(n));
|
|
131
|
+
return Promise.all(names.map(async (name) => {
|
|
132
|
+
const [cols, idxList] = await Promise.all([store.query(`SELECT name, type, "notnull" AS not_null, pk FROM pragma_table_info(${lit(name)})`), store.query(`SELECT name, "unique" AS is_unique FROM pragma_index_list(${lit(name)})`)]);
|
|
133
|
+
const indexes = await Promise.all(idxList.results.filter((idx) => !RESERVED.test(String(idx.name))).map(async (idx) => ({
|
|
134
|
+
name: String(idx.name),
|
|
135
|
+
unique: Boolean(Number(idx.is_unique)),
|
|
136
|
+
columns: (await store.query(`SELECT name FROM pragma_index_info(${lit(String(idx.name))})`)).results.map((r) => String(r.name))
|
|
137
|
+
})));
|
|
138
|
+
return {
|
|
139
|
+
name,
|
|
140
|
+
columns: cols.results.map((col) => ({
|
|
141
|
+
name: String(col.name),
|
|
142
|
+
type: String(col.type),
|
|
143
|
+
nullable: !Number(col.not_null),
|
|
144
|
+
pk: Number(col.pk) > 0
|
|
145
|
+
})),
|
|
146
|
+
indexes
|
|
147
|
+
};
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
async function postgresSchema(store) {
|
|
151
|
+
const [tables, columns, pks, indexes] = await Promise.all([
|
|
152
|
+
store.query(`SELECT table_name AS name FROM information_schema.tables
|
|
153
|
+
WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'
|
|
154
|
+
ORDER BY table_name`),
|
|
155
|
+
store.query(`SELECT table_name, column_name, data_type, is_nullable
|
|
156
|
+
FROM information_schema.columns
|
|
157
|
+
WHERE table_schema = current_schema()
|
|
158
|
+
ORDER BY table_name, ordinal_position`),
|
|
159
|
+
store.query(`SELECT tc.table_name, kcu.column_name
|
|
160
|
+
FROM information_schema.table_constraints tc
|
|
161
|
+
JOIN information_schema.key_column_usage kcu
|
|
162
|
+
ON kcu.constraint_name = tc.constraint_name
|
|
163
|
+
AND kcu.table_schema = tc.table_schema
|
|
164
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = current_schema()`),
|
|
165
|
+
store.query(`SELECT t.relname AS table_name, i.relname AS index_name,
|
|
166
|
+
ix.indisunique AS is_unique, a.attname AS column_name
|
|
167
|
+
FROM pg_index ix
|
|
168
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
169
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
170
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
171
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
172
|
+
WHERE n.nspname = current_schema() AND NOT ix.indisprimary`)
|
|
173
|
+
]);
|
|
174
|
+
const pkSet = new Set(pks.results.map((r) => `${String(r.table_name)}.${String(r.column_name)}`));
|
|
175
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
176
|
+
for (const row of tables.results) {
|
|
177
|
+
const name = String(row.name);
|
|
178
|
+
if (!RESERVED.test(name)) byTable.set(name, {
|
|
179
|
+
name,
|
|
180
|
+
columns: [],
|
|
181
|
+
indexes: []
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
for (const col of columns.results) {
|
|
185
|
+
const table = byTable.get(String(col.table_name));
|
|
186
|
+
if (!table) continue;
|
|
187
|
+
table.columns.push({
|
|
188
|
+
name: String(col.column_name),
|
|
189
|
+
type: String(col.data_type),
|
|
190
|
+
nullable: col.is_nullable === "YES",
|
|
191
|
+
pk: pkSet.has(`${String(col.table_name)}.${String(col.column_name)}`)
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const idx of indexes.results) {
|
|
195
|
+
const table = byTable.get(String(idx.table_name));
|
|
196
|
+
if (!table) continue;
|
|
197
|
+
const name = String(idx.index_name);
|
|
198
|
+
let index = table.indexes.find((i) => i.name === name);
|
|
199
|
+
if (!index) {
|
|
200
|
+
index = {
|
|
201
|
+
name,
|
|
202
|
+
unique: Boolean(idx.is_unique),
|
|
203
|
+
columns: []
|
|
204
|
+
};
|
|
205
|
+
table.indexes.push(index);
|
|
206
|
+
}
|
|
207
|
+
index.columns.push(String(idx.column_name));
|
|
208
|
+
}
|
|
209
|
+
return [...byTable.values()];
|
|
210
|
+
}
|
|
211
|
+
async function introspectSchema(store, dialect) {
|
|
212
|
+
const [tables, collections] = await Promise.all([dialect === "postgres" ? postgresSchema(store) : sqliteSchema(store), store.query("SELECT collection, COUNT(*) AS docs FROM _canopy_docs GROUP BY collection ORDER BY collection").then((r) => r.results.map((row) => ({
|
|
213
|
+
name: String(row.collection),
|
|
214
|
+
docs: Number(row.docs)
|
|
215
|
+
}))).catch(() => [])]);
|
|
216
|
+
return {
|
|
217
|
+
dialect,
|
|
218
|
+
collections,
|
|
219
|
+
tables
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region ../data-plane/src/db.ts
|
|
224
|
+
/**
|
|
225
|
+
* /db — the SDK's document API + raw-SQL escape hatch, over an injectable
|
|
226
|
+
* DocStore. This is THE data-plane implementation: canopy-api mounts it under
|
|
227
|
+
* /data/v1 against project D1s (or Postgres on GCP), and `canopy dev --local`
|
|
228
|
+
* mounts the very same router against PGlite — one place to change when the
|
|
229
|
+
* API surface moves.
|
|
230
|
+
*
|
|
231
|
+
* Paths carry no project: hosts wrap the router with their own auth/scope
|
|
232
|
+
* middleware, resolve the project themselves (gateway-stamped
|
|
233
|
+
* X-Canopy-Project or token scope — see canopy-api's session.ts), and hand
|
|
234
|
+
* back the store in `config.store` (returning a Response for not-found/paused…).
|
|
235
|
+
*/
|
|
236
|
+
const COLLECTION_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
237
|
+
const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
238
|
+
const MAX_DOC_BYTES = 256 * 1024;
|
|
239
|
+
const MAX_LIMIT = 1e3;
|
|
240
|
+
/** Row cap for versioned /db/sql responses (the legacy shape is uncapped). */
|
|
241
|
+
const MAX_SQL_ROWS = 1e3;
|
|
242
|
+
/**
|
|
243
|
+
* Statement classifier for read-only /db/sql runs. First keyword must be a
|
|
244
|
+
* read; WITH additionally may not smuggle DML in a CTE (impossible on SQLite,
|
|
245
|
+
* possible on Postgres — where hosts also enforce `SET TRANSACTION READ ONLY`
|
|
246
|
+
* at the engine, making this the belt to that suspender).
|
|
247
|
+
*/
|
|
248
|
+
function readOnlySqlViolation(query) {
|
|
249
|
+
const bare = query.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, "\"\"");
|
|
250
|
+
const first = /^\s*([A-Za-z]+)/.exec(bare)?.[1]?.toUpperCase();
|
|
251
|
+
if (first === void 0) return "empty statement";
|
|
252
|
+
if (![
|
|
253
|
+
"SELECT",
|
|
254
|
+
"EXPLAIN",
|
|
255
|
+
"VALUES",
|
|
256
|
+
"WITH"
|
|
257
|
+
].includes(first)) return `${first} is not allowed in read-only mode`;
|
|
258
|
+
const dml = /\b(INSERT|UPDATE|DELETE|MERGE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|GRANT|REVOKE|VACUUM|ATTACH|REINDEX)\b/i.exec(bare);
|
|
259
|
+
if (first === "WITH" && dml) return `${dml[1].toUpperCase()} is not allowed in read-only mode`;
|
|
260
|
+
if (first === "EXPLAIN" && dml && /\bANALYZE\b/i.test(bare)) return "EXPLAIN ANALYZE on a write is not allowed in read-only mode";
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
function toDoc(row) {
|
|
264
|
+
return {
|
|
265
|
+
...JSON.parse(row.data),
|
|
266
|
+
_id: row.id,
|
|
267
|
+
_createdAt: row.created_at,
|
|
268
|
+
_updatedAt: row.updated_at
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function isPlainObject(value) {
|
|
272
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
273
|
+
}
|
|
274
|
+
/** User fields only — `_`-prefixed keys are platform-managed. */
|
|
275
|
+
function stripMetaKeys(doc) {
|
|
276
|
+
return Object.fromEntries(Object.entries(doc).filter(([key]) => !key.startsWith("_")));
|
|
277
|
+
}
|
|
278
|
+
function isScalar(value) {
|
|
279
|
+
return value === null || [
|
|
280
|
+
"string",
|
|
281
|
+
"number",
|
|
282
|
+
"boolean"
|
|
283
|
+
].includes(typeof value);
|
|
284
|
+
}
|
|
285
|
+
var BadQuery = class extends Error {};
|
|
286
|
+
/** Validate a where object: known meta keys / dot-path fields, scalar values. */
|
|
287
|
+
function validateWhere(where) {
|
|
288
|
+
for (const [field, value] of Object.entries(where)) {
|
|
289
|
+
if (!isScalar(value)) throw new BadQuery(`where.${field}: only string/number/boolean/null values are supported`);
|
|
290
|
+
if (!META_COLUMNS[field] && !FIELD_RE.test(field)) throw new BadQuery(`where.${field}: invalid field name`);
|
|
291
|
+
}
|
|
292
|
+
return where;
|
|
293
|
+
}
|
|
294
|
+
function intParam(raw, name, fallback, max) {
|
|
295
|
+
if (raw === void 0) return fallback;
|
|
296
|
+
if (!/^\d{1,9}$/.test(raw)) throw new BadQuery(`${name} must be a non-negative integer`);
|
|
297
|
+
return Math.min(Number(raw), max);
|
|
298
|
+
}
|
|
299
|
+
function dbRouter(config) {
|
|
300
|
+
const db = new Hono();
|
|
301
|
+
const resolve = async (c) => config.store(c);
|
|
302
|
+
db.post("/db/sql", async (c) => {
|
|
303
|
+
const resolved = await resolve(c);
|
|
304
|
+
if (resolved instanceof Response) return resolved;
|
|
305
|
+
const { store } = resolved;
|
|
306
|
+
const body = await c.req.json().catch(() => ({}));
|
|
307
|
+
const params = body.params ?? [];
|
|
308
|
+
if (typeof body.query !== "string" || body.query.trim() === "" || !Array.isArray(params) || !params.every(isScalar) || !(body.readonly === void 0 || typeof body.readonly === "boolean")) return c.json({
|
|
309
|
+
error: "invalid_query",
|
|
310
|
+
detail: "expected { query: string, params?: (string|number|boolean|null)[], readonly?: boolean }"
|
|
311
|
+
}, 400);
|
|
312
|
+
if (body.readonly === void 0) {
|
|
313
|
+
const result = await store.query(body.query, params).catch((err) => err);
|
|
314
|
+
if (result instanceof Error) return c.json({
|
|
315
|
+
error: "sql_error",
|
|
316
|
+
detail: result.message
|
|
317
|
+
}, 400);
|
|
318
|
+
return c.json(result.results);
|
|
319
|
+
}
|
|
320
|
+
if (body.readonly) {
|
|
321
|
+
const violation = readOnlySqlViolation(body.query);
|
|
322
|
+
if (violation) return c.json({
|
|
323
|
+
error: "read_only",
|
|
324
|
+
detail: violation
|
|
325
|
+
}, 400);
|
|
326
|
+
}
|
|
327
|
+
const result = await store.query(body.query, params, { readonly: body.readonly }).catch((err) => err);
|
|
328
|
+
if (result instanceof Error) return c.json({
|
|
329
|
+
error: "sql_error",
|
|
330
|
+
detail: result.message
|
|
331
|
+
}, 400);
|
|
332
|
+
if (!body.readonly) await config.onSqlWrite?.(c, body.query);
|
|
333
|
+
const truncated = result.results.length > MAX_SQL_ROWS;
|
|
334
|
+
const rows = truncated ? result.results.slice(0, MAX_SQL_ROWS) : result.results;
|
|
335
|
+
return c.json({
|
|
336
|
+
rows,
|
|
337
|
+
rowCount: rows.length,
|
|
338
|
+
truncated
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
db.get("/db/schema", async (c) => {
|
|
342
|
+
const resolved = await resolve(c);
|
|
343
|
+
if (resolved instanceof Response) return resolved;
|
|
344
|
+
const schema = await introspectSchema(resolved.store, resolved.dialect.name).catch((err) => err);
|
|
345
|
+
if (schema instanceof Error) return c.json({
|
|
346
|
+
error: "sql_error",
|
|
347
|
+
detail: schema.message
|
|
348
|
+
}, 400);
|
|
349
|
+
return c.json(schema);
|
|
350
|
+
});
|
|
351
|
+
db.post("/db/:collection", async (c) => {
|
|
352
|
+
const resolved = await resolve(c);
|
|
353
|
+
if (resolved instanceof Response) return resolved;
|
|
354
|
+
const { store, dialect } = resolved;
|
|
355
|
+
const collection = c.req.param("collection");
|
|
356
|
+
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
357
|
+
const body = await c.req.json().catch(() => void 0);
|
|
358
|
+
if (!isPlainObject(body)) return c.json({
|
|
359
|
+
error: "invalid_document",
|
|
360
|
+
detail: "expected a JSON object"
|
|
361
|
+
}, 400);
|
|
362
|
+
const fields = stripMetaKeys(body);
|
|
363
|
+
const data = JSON.stringify(fields);
|
|
364
|
+
if (data.length > MAX_DOC_BYTES) return c.json({
|
|
365
|
+
error: "document_too_large",
|
|
366
|
+
maxBytes: MAX_DOC_BYTES
|
|
367
|
+
}, 413);
|
|
368
|
+
const id = crypto.randomUUID();
|
|
369
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
370
|
+
const insert = dialect.insert(collection, id, data, now);
|
|
371
|
+
await store.query(insert.sql, insert.params);
|
|
372
|
+
return c.json({
|
|
373
|
+
...fields,
|
|
374
|
+
_id: id,
|
|
375
|
+
_createdAt: now,
|
|
376
|
+
_updatedAt: now
|
|
377
|
+
}, 201);
|
|
378
|
+
});
|
|
379
|
+
db.get("/db/:collection", async (c) => {
|
|
380
|
+
const resolved = await resolve(c);
|
|
381
|
+
if (resolved instanceof Response) return resolved;
|
|
382
|
+
const { store, dialect } = resolved;
|
|
383
|
+
const collection = c.req.param("collection");
|
|
384
|
+
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
385
|
+
const q = c.req.query();
|
|
386
|
+
try {
|
|
387
|
+
const limit = intParam(q.limit, "limit", 100, MAX_LIMIT);
|
|
388
|
+
const offset = intParam(q.offset, "offset", 0, 1e9);
|
|
389
|
+
let whereRaw = {};
|
|
390
|
+
if (q.where !== void 0) {
|
|
391
|
+
try {
|
|
392
|
+
whereRaw = JSON.parse(q.where);
|
|
393
|
+
} catch {
|
|
394
|
+
throw new BadQuery("where must be a JSON object");
|
|
395
|
+
}
|
|
396
|
+
if (!isPlainObject(whereRaw)) throw new BadQuery("where must be a JSON object");
|
|
397
|
+
}
|
|
398
|
+
const where = validateWhere(whereRaw);
|
|
399
|
+
let query;
|
|
400
|
+
if (q.search !== void 0) {
|
|
401
|
+
query = dialect.search({
|
|
402
|
+
collection,
|
|
403
|
+
text: q.search,
|
|
404
|
+
where,
|
|
405
|
+
limit,
|
|
406
|
+
offset
|
|
407
|
+
});
|
|
408
|
+
if (query === null) return c.json([]);
|
|
409
|
+
} else {
|
|
410
|
+
if (q.orderBy !== void 0 && !META_COLUMNS[q.orderBy] && !FIELD_RE.test(q.orderBy)) throw new BadQuery("orderBy: invalid field name");
|
|
411
|
+
if (q.order !== void 0 && q.order !== "asc" && q.order !== "desc") throw new BadQuery("order must be 'asc' or 'desc'");
|
|
412
|
+
query = dialect.find({
|
|
413
|
+
collection,
|
|
414
|
+
where,
|
|
415
|
+
orderBy: q.orderBy,
|
|
416
|
+
order: q.order === "desc" ? "desc" : "asc",
|
|
417
|
+
limit,
|
|
418
|
+
offset
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
const result = await store.query(query.sql, query.params);
|
|
422
|
+
return c.json(result.results.map(toDoc));
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if (err instanceof BadQuery) return c.json({
|
|
425
|
+
error: "invalid_query",
|
|
426
|
+
detail: err.message
|
|
427
|
+
}, 400);
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
db.get("/db/:collection/:id", async (c) => {
|
|
432
|
+
const resolved = await resolve(c);
|
|
433
|
+
if (resolved instanceof Response) return resolved;
|
|
434
|
+
const { store, dialect } = resolved;
|
|
435
|
+
const collection = c.req.param("collection");
|
|
436
|
+
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
437
|
+
const get = dialect.get(collection, c.req.param("id"));
|
|
438
|
+
const row = (await store.query(get.sql, get.params)).results[0];
|
|
439
|
+
return c.json(row ? toDoc(row) : null);
|
|
440
|
+
});
|
|
441
|
+
db.patch("/db/:collection/:id", async (c) => {
|
|
442
|
+
const resolved = await resolve(c);
|
|
443
|
+
if (resolved instanceof Response) return resolved;
|
|
444
|
+
const { store, dialect } = resolved;
|
|
445
|
+
const collection = c.req.param("collection");
|
|
446
|
+
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
447
|
+
const body = await c.req.json().catch(() => void 0);
|
|
448
|
+
if (!isPlainObject(body)) return c.json({
|
|
449
|
+
error: "invalid_patch",
|
|
450
|
+
detail: "expected a JSON object"
|
|
451
|
+
}, 400);
|
|
452
|
+
const patch = dialect.patch(collection, c.req.param("id"), JSON.stringify(stripMetaKeys(body)), (/* @__PURE__ */ new Date()).toISOString());
|
|
453
|
+
const row = (await store.query(patch.sql, patch.params)).results[0];
|
|
454
|
+
if (!row) return c.json({ error: "not_found" }, 404);
|
|
455
|
+
return c.json(toDoc(row));
|
|
456
|
+
});
|
|
457
|
+
db.delete("/db/:collection/:id", async (c) => {
|
|
458
|
+
const resolved = await resolve(c);
|
|
459
|
+
if (resolved instanceof Response) return resolved;
|
|
460
|
+
const { store, dialect } = resolved;
|
|
461
|
+
const collection = c.req.param("collection");
|
|
462
|
+
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
463
|
+
const del = dialect.del(collection, c.req.param("id"));
|
|
464
|
+
const result = await store.query(del.sql, del.params);
|
|
465
|
+
return c.json({ deleted: (result.meta.changes ?? 0) > 0 });
|
|
466
|
+
});
|
|
467
|
+
return db;
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
export { META_COLUMNS as n, blobRouter as r, dbRouter as t };
|
|
@@ -112,7 +112,7 @@ const upgradeCommand = defineCommand({
|
|
|
112
112
|
const spec = `${PACKAGE_NAME}@${args.version ?? "latest"}`;
|
|
113
113
|
const command = pmCommand(pm);
|
|
114
114
|
const cmd = installArgs(pm, spec);
|
|
115
|
-
const { skillInstalled } = await import("./skill-
|
|
115
|
+
const { skillInstalled } = await import("./skill-BOWfqwr2.mjs");
|
|
116
116
|
const refreshSkill = skillInstalled(process.cwd());
|
|
117
117
|
console.log(`Upgrading via ${command}: ${command} ${cmd.join(" ")}`);
|
|
118
118
|
await run(command, cmd);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mediamonks/canopy-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
4
4
|
"description": "Canopy CLI — create, develop and deploy Canopy projects.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"canopy": "./dist/index.mjs"
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"esbuild": "^0.28.1",
|
|
21
21
|
"hono": "^4.8.0",
|
|
22
22
|
"vite-server": "npm:vite@^7",
|
|
23
|
-
"@mediamonks/canopy": "0.0.
|
|
24
|
-
"@mediamonks/canopy-data-plane": "0.0.
|
|
23
|
+
"@mediamonks/canopy": "0.0.5",
|
|
24
|
+
"@mediamonks/canopy-data-plane": "0.0.4"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.1",
|
package/dist/dev-BYxSHUH6.mjs
DELETED
package/dist/docs/ci.md
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: CI and deploy tokens
|
|
3
|
-
description: Push-to-main deploys using a project-scoped deploy token.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Interactive `canopy login` is for humans at a terminal. For automated deploys
|
|
7
|
-
(GitHub Actions, etc.) use a **deploy token**: a project-scoped credential that
|
|
8
|
-
can deploy that one project and nothing else.
|
|
9
|
-
|
|
10
|
-
## Create a token
|
|
11
|
-
|
|
12
|
-
From inside the project directory:
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
canopy token create --name "GitHub Actions"
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
This prints the token **once** — copy it immediately and store it as a CI secret
|
|
19
|
-
(e.g. `CANOPY_DEPLOY_TOKEN`). It is not shown again.
|
|
20
|
-
|
|
21
|
-
Manage tokens:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
canopy token list
|
|
25
|
-
canopy token revoke <id>
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
## Use it in CI
|
|
29
|
-
|
|
30
|
-
The CLI picks up `CANOPY_DEPLOY_TOKEN` from the environment and uses it instead
|
|
31
|
-
of an interactive login. A minimal GitHub Actions step:
|
|
32
|
-
|
|
33
|
-
```yaml
|
|
34
|
-
- run: npm install -g @mediamonks/canopy-cli
|
|
35
|
-
- run: canopy deploy
|
|
36
|
-
env:
|
|
37
|
-
CANOPY_DEPLOY_TOKEN: ${{ secrets.CANOPY_DEPLOY_TOKEN }}
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
(`canopy deploy` runs the project's `canopy.json` build command by default;
|
|
41
|
-
pass `--no-build` if CI already built the output directory.)
|
|
42
|
-
|
|
43
|
-
No `canopy login` step is needed — the token is the credential.
|
|
44
|
-
|
|
45
|
-
## Notes
|
|
46
|
-
|
|
47
|
-
- A deploy token can only deploy its own project — it reaches that project's
|
|
48
|
-
deploy endpoints and upload grants and nothing else. If it strays to another
|
|
49
|
-
project's resources, the platform rejects it.
|
|
50
|
-
- Revoke a token the moment it might be exposed; create a fresh one to replace it.
|
package/dist/docs/cli-create.md
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: canopy create
|
|
3
|
-
description: Register a new project on Canopy and scaffold it locally from a starter template.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
`canopy create` is the start of every new project: it registers the project on Canopy (claiming its slug and live URL) and scaffolds a runnable starter into `./<slug>/`. Everything is prompted for interactively, or passable as flags for scripted use.
|
|
7
|
-
|
|
8
|
-
## Usage
|
|
9
|
-
|
|
10
|
-
```bash
|
|
11
|
-
canopy create # fully interactive
|
|
12
|
-
canopy create "My Portfolio" --slug my-portfolio --template spa
|
|
13
|
-
canopy create existing-app --no-scaffold # register only, no files written
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## Arguments & flags
|
|
17
|
-
|
|
18
|
-
- `<name>` — the project's display name. Multi-word names work unquoted (every positional joins the name). Prompted for when omitted on a TTY.
|
|
19
|
-
- `--slug <slug>` — the URL slug; the project will live at `https://<slug>.<site-domain>`. Lowercase letters, digits and dashes only. Defaults to a slugified version of the name (prompted on a TTY).
|
|
20
|
-
- `--template <id>` — starter to scaffold: `static` (plain HTML/CSS/JS, no build step, untyped SDK from the platform — simplest apps), `spa` (Vite + React + TypeScript with the typed SDK from npm — best for real SDK use or growth), or `worker` (`spa` plus a backend worker in `worker/` — for server-side code: secrets, schedules, calling external APIs). A picker appears when omitted on a TTY; `static` is the default otherwise.
|
|
21
|
-
- `--scaffold` / `--no-scaffold` — whether to write `./<slug>/` from the template. When neither is given, the CLI scaffolds unless the current directory already has a `canopy.json` (in which case it assumes you're registering an existing app and only registers).
|
|
22
|
-
- `--profile <site>` — global flag (or `CANOPY_PROFILE`): which site to create the project on. The chosen profile is pinned into the scaffolded `canopy.json`.
|
|
23
|
-
|
|
24
|
-
## How it works
|
|
25
|
-
|
|
26
|
-
The project is registered with the platform first — this is what reserves the slug and creates the entry you'll see in `canopy projects` and the dashboard. Taken, reserved, or invalid slugs fail here with an actionable error, before anything touches disk.
|
|
27
|
-
|
|
28
|
-
With scaffolding on, the CLI then copies the template into `./<slug>/` (refusing if that directory already exists), substituting the name and slug into the files, and writes a `canopy.json` containing the slug and the profile pin. Templates ship a README, an `AGENTS.md`, and a `.gitignore`; the `spa` template also needs an `npm install` before `canopy dev`. With `--no-scaffold`, nothing is written — an existing `canopy.json` is left untouched, and you're told to add one if the directory has none.
|
|
29
|
-
|
|
30
|
-
The live URL is printed at the end, but it only serves content after your first `canopy deploy`.
|
|
31
|
-
|
|
32
|
-
## Examples
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
canopy create
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
Walks you through name, slug, and template, registers the project, and scaffolds a ready-to-run starter with next steps printed.
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
canopy create "Team Retro Board" --slug retro-board --template spa
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Registers `retro-board`, scaffolds a React SPA into `./retro-board/`, and tells you to `npm install` then `canopy dev`.
|
|
45
|
-
|
|
46
|
-
To set up a local folder for a project that already exists on Canopy, use [canopy init](https://docs.canopy.website/cli/init) instead. See also [canopy projects](https://docs.canopy.website/cli/projects) and [canopy delete](https://docs.canopy.website/cli/delete).
|