@mediamonks/canopy-cli 0.7.23 → 0.7.24
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/db-jmYaum8F.mjs +251 -0
- package/dist/{deploy-DCggsLD7.mjs → deploy-CYKiBnrg.mjs} +3 -3
- package/dist/dev-C5BmSJo1.mjs +2 -0
- package/dist/{dev-XULukupE.mjs → dev-DVUEJ80c.mjs} +6 -6
- package/dist/{dev-worker-BDMjExps.mjs → dev-worker-CWU6XFe_.mjs} +1 -1
- package/dist/docs/ci.md +8 -1
- package/dist/docs/cli-egress.md +2 -1
- package/dist/docs/cli-token.md +4 -2
- package/dist/docs/platform-client-access.md +9 -0
- package/dist/docs/platform-egress.md +5 -3
- package/dist/docs/platform-project-data.md +1 -0
- package/dist/docs/sdk-database.md +30 -0
- package/dist/{docs-BDPL0TI3.mjs → docs-BLWio_65.mjs} +3 -3
- package/dist/{egress-BhbQfjjV.mjs → egress-BjN-7vil.mjs} +6 -3
- package/dist/{feedback-B3pnUPLf.mjs → feedback-Bnmi1Ukv.mjs} +3 -3
- package/dist/index.mjs +26 -26
- package/dist/{local-BjsuI1IB.mjs → local-CNPBZ89U.mjs} +3 -3
- package/dist/{login-By4y5O5-.mjs → login-COZ7Xcm0.mjs} +2 -2
- package/dist/{logs-Beqz--Df.mjs → logs-B_YoflCq.mjs} +2 -2
- package/dist/{migrations-Bg9FEGFh.mjs → migrations-Btzr9_q7.mjs} +2 -2
- package/dist/{open-D7f1wNn3.mjs → open-D2hH933K.mjs} +2 -2
- package/dist/{package-C5rZwlw8.mjs → package-DTm0EzxR.mjs} +1 -1
- package/dist/{projects-1r5387ui.mjs → projects-CLs6MEF7.mjs} +2 -2
- package/dist/{schedule-DK_Cv6Xf.mjs → schedule-B8zA8WlG.mjs} +2 -2
- package/dist/{site-BFaWfSVu.mjs → site-DM2J10CB.mjs} +1 -1
- package/dist/{skill-CgToW_Ac.mjs → skill-Dt49jdUT.mjs} +2 -2
- package/dist/{src-BPKfY9IN.mjs → src-BK0Hiwsn.mjs} +42 -2
- package/dist/{staging-CdpV2YHB.mjs → staging-pZpdZuj_.mjs} +2 -2
- package/dist/{token-C8Bmzwfl.mjs → token-BKwGa10J.mjs} +8 -4
- package/dist/{ui--atbZ-TM.mjs → ui-CUNkis89.mjs} +1 -1
- package/dist/{upgrade-Bl_NJ_m5.mjs → upgrade-Cf81feok.mjs} +2 -2
- package/dist/{usage-C1-cvS2-.mjs → usage-UmzU6sGp.mjs} +2 -2
- package/package.json +1 -1
- package/dist/db-TaSLpQZ5.mjs +0 -204
- package/dist/dev-BFPlhkzp.mjs +0 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, s as apiDataRequest } from "./ui-CUNkis89.mjs";
|
|
3
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Btzr9_q7.mjs";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
//#region src/db.ts
|
|
8
|
+
/**
|
|
9
|
+
* canopy db pull | snapshot | snapshots | restore — data safety from the
|
|
10
|
+
* terminal (docs/04-roadmap.md Phase 2 item 4, pulled forward).
|
|
11
|
+
*
|
|
12
|
+
* pull download a fresh SQL dump of the project database
|
|
13
|
+
* snapshot store a dump server-side (restorable from CLI or dashboard)
|
|
14
|
+
* snapshots list stored snapshots
|
|
15
|
+
* restore replace ALL project data from a snapshot or a local .sql file
|
|
16
|
+
* migrate apply pending schema migrations (docs/13-migrations-and-drizzle.md)
|
|
17
|
+
* access show or set what the app's pages may do with the database (docs/35)
|
|
18
|
+
*
|
|
19
|
+
* The project comes from canopy.json in the current directory, or
|
|
20
|
+
* --project <slug>.
|
|
21
|
+
*/
|
|
22
|
+
/** Shared flag: operate on the staging channel's database (docs/14). */
|
|
23
|
+
const stagingFlagArgs = { staging: {
|
|
24
|
+
type: "boolean",
|
|
25
|
+
default: false,
|
|
26
|
+
description: "Target the staging channel's database instead of live"
|
|
27
|
+
} };
|
|
28
|
+
const channelOf = (args) => args.staging ? "staging" : "live";
|
|
29
|
+
const channelNote = (args) => args.staging ? " [staging]" : "";
|
|
30
|
+
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
31
|
+
const pullCommand = defineCommand({
|
|
32
|
+
meta: {
|
|
33
|
+
name: "pull",
|
|
34
|
+
description: "Download a fresh SQL dump of the project database"
|
|
35
|
+
},
|
|
36
|
+
args: {
|
|
37
|
+
...projectFlagArgs,
|
|
38
|
+
...stagingFlagArgs,
|
|
39
|
+
out: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Output file (defaults to <slug>-<timestamp>.sql)"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
run: reportErrors("db pull", async ({ args }) => {
|
|
45
|
+
const { slug } = resolveProject(args);
|
|
46
|
+
const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
|
|
47
|
+
const spin = p.spinner();
|
|
48
|
+
spin.start(`Exporting ${slug}${channelNote(args)}…`);
|
|
49
|
+
try {
|
|
50
|
+
const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channelOf(args));
|
|
51
|
+
const download = await fetch(url);
|
|
52
|
+
if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
|
|
53
|
+
const sql = Buffer.from(await download.arrayBuffer());
|
|
54
|
+
writeFileSync(out, sql);
|
|
55
|
+
spin.stop(`Wrote ${out} (${formatSize(sql.byteLength)}).`);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
spin.error("Export failed.");
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
});
|
|
62
|
+
const snapshotCommand = defineCommand({
|
|
63
|
+
meta: {
|
|
64
|
+
name: "snapshot",
|
|
65
|
+
description: "Store a dump server-side (restorable anytime)"
|
|
66
|
+
},
|
|
67
|
+
args: {
|
|
68
|
+
...projectFlagArgs,
|
|
69
|
+
...stagingFlagArgs
|
|
70
|
+
},
|
|
71
|
+
run: reportErrors("db snapshot", async ({ args }) => {
|
|
72
|
+
const { slug } = resolveProject(args);
|
|
73
|
+
const spin = p.spinner();
|
|
74
|
+
spin.start(`Snapshotting ${slug}${channelNote(args)}…`);
|
|
75
|
+
let created;
|
|
76
|
+
try {
|
|
77
|
+
({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug, void 0, void 0, channelOf(args)));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
spin.error("Snapshot failed.");
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
spin.stop(`Stored ${created.key} (${formatSize(created.size)}).`);
|
|
83
|
+
console.log(`Restore anytime with \`canopy db restore --snapshot ${created.key}\`.`);
|
|
84
|
+
})
|
|
85
|
+
});
|
|
86
|
+
const snapshotsCommand = defineCommand({
|
|
87
|
+
meta: {
|
|
88
|
+
name: "snapshots",
|
|
89
|
+
description: "List stored snapshots"
|
|
90
|
+
},
|
|
91
|
+
args: {
|
|
92
|
+
...projectFlagArgs,
|
|
93
|
+
...stagingFlagArgs
|
|
94
|
+
},
|
|
95
|
+
run: reportErrors("db snapshots", async ({ args }) => {
|
|
96
|
+
const { slug } = resolveProject(args);
|
|
97
|
+
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
|
|
98
|
+
if (snapshots.length === 0) {
|
|
99
|
+
console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const snapshot of snapshots) console.log(`${snapshot.key} ${formatSize(snapshot.size).padStart(9)} ${snapshot.createdAt}`);
|
|
103
|
+
})
|
|
104
|
+
});
|
|
105
|
+
const restoreCommand = defineCommand({
|
|
106
|
+
meta: {
|
|
107
|
+
name: "restore",
|
|
108
|
+
description: "Replace ALL project data from a snapshot or a local .sql file"
|
|
109
|
+
},
|
|
110
|
+
args: {
|
|
111
|
+
...projectFlagArgs,
|
|
112
|
+
...stagingFlagArgs,
|
|
113
|
+
file: {
|
|
114
|
+
type: "positional",
|
|
115
|
+
description: "Local .sql dump to restore from",
|
|
116
|
+
required: false
|
|
117
|
+
},
|
|
118
|
+
snapshot: {
|
|
119
|
+
type: "string",
|
|
120
|
+
description: "Server-side snapshot key to restore from"
|
|
121
|
+
},
|
|
122
|
+
yes: {
|
|
123
|
+
type: "boolean",
|
|
124
|
+
default: false,
|
|
125
|
+
description: "Skip the type-the-slug confirmation"
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
run: reportErrors("db restore", async ({ args }) => {
|
|
129
|
+
const { slug } = resolveProject(args);
|
|
130
|
+
const { file } = args;
|
|
131
|
+
let snapshotKey = args.snapshot;
|
|
132
|
+
if (!snapshotKey && !file) {
|
|
133
|
+
requireTty("pass a local .sql dump or --snapshot <key>.");
|
|
134
|
+
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
|
|
135
|
+
if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
|
|
136
|
+
snapshotKey = unwrap(await p.select({
|
|
137
|
+
message: `Snapshot to restore into ${slug}`,
|
|
138
|
+
options: snapshots.map((snapshot) => ({
|
|
139
|
+
value: snapshot.key,
|
|
140
|
+
label: snapshot.key,
|
|
141
|
+
hint: `${formatSize(snapshot.size)} · ${snapshot.createdAt}`
|
|
142
|
+
}))
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
if (!args.yes) {
|
|
146
|
+
requireTty("pass --yes to restore without confirmation.");
|
|
147
|
+
if (unwrap(await p.text({ message: `Restore will REPLACE all current data in "${slug}"${channelNote(args)} with ${snapshotKey ?? file}. Type the slug to confirm.` })).trim() !== slug) {
|
|
148
|
+
p.cancel("Aborted.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
|
|
153
|
+
if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }, void 0, channelOf(args)), restoreContext);
|
|
154
|
+
else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql", channelOf(args)), restoreContext);
|
|
155
|
+
p.log.success(`Restored ${slug}${channelNote(args)}.`);
|
|
156
|
+
})
|
|
157
|
+
});
|
|
158
|
+
const migrateCommand = defineCommand({
|
|
159
|
+
meta: {
|
|
160
|
+
name: "migrate",
|
|
161
|
+
description: "Apply pending schema migrations to the project database"
|
|
162
|
+
},
|
|
163
|
+
args: {
|
|
164
|
+
...projectFlagArgs,
|
|
165
|
+
...stagingFlagArgs,
|
|
166
|
+
plan: {
|
|
167
|
+
type: "boolean",
|
|
168
|
+
default: false,
|
|
169
|
+
description: "Dry run: list pending migrations without applying anything"
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
run: reportErrors("db migrate", async ({ args }) => {
|
|
173
|
+
const { dir, slug } = resolveProject(args);
|
|
174
|
+
const files = readMigrationFiles(dir);
|
|
175
|
+
if (files === void 0) throw new Error("No migrations directory configured — set \"migrations\" in canopy.json (e.g. {\"migrations\": \"./migrations\"}, pointing at your drizzle-kit output).");
|
|
176
|
+
if (args.plan) {
|
|
177
|
+
const result = await withContext(migrateProject(slug, files, true, channelOf(args)), { action: `plan migrations for "${slug}"${channelNote(args)}` });
|
|
178
|
+
if (result.pending.length === 0) console.log(`Nothing pending (${result.applied.length} applied previously).`);
|
|
179
|
+
else for (const name of result.pending) console.log(`pending ${name}`);
|
|
180
|
+
for (const line of describeMigrateResult(result)) console.log(line);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const spin = p.spinner();
|
|
184
|
+
spin.start(`Migrating ${slug}${channelNote(args)}…`);
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = await withContext(migrateProject(slug, files, false, channelOf(args)), { action: `migrate "${slug}"${channelNote(args)}` });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
spin.error("Migrate failed.");
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
spin.stop(result.appliedNow.length === 0 ? `Nothing pending for ${slug}.` : `Applied ${result.appliedNow.length} migration(s) to ${slug}.`);
|
|
193
|
+
for (const line of describeMigrateResult(result)) console.log(` ${line}`);
|
|
194
|
+
})
|
|
195
|
+
});
|
|
196
|
+
const LEVEL_MEANING = {
|
|
197
|
+
full: "pages can read and write the database (collections and raw SQL)",
|
|
198
|
+
readonly: "pages can read; SQL that writes and collection writes are refused",
|
|
199
|
+
off: "pages cannot touch the database — only the worker and the CLI can"
|
|
200
|
+
};
|
|
201
|
+
function printAccess(slug, access) {
|
|
202
|
+
console.log(`${slug}: browser access is ${access.level} — ${LEVEL_MEANING[access.level]}.`);
|
|
203
|
+
console.log("Tables and collections named private_… are worker-only at every level.");
|
|
204
|
+
if (access.clientAccess && access.level !== "off") {
|
|
205
|
+
console.log("");
|
|
206
|
+
console.log(` ! Client access is on: visitors who know the password can ${access.level === "full" ? "read and change" : "read"} this data from their browser.`);
|
|
207
|
+
console.log(" Name tables private_… to keep them worker-only, or `canopy db access off`.");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const dbCommand = defineCommand({
|
|
211
|
+
meta: {
|
|
212
|
+
name: "db",
|
|
213
|
+
description: "Data tools: pull, snapshot, snapshots, restore, migrate, access"
|
|
214
|
+
},
|
|
215
|
+
subCommands: {
|
|
216
|
+
pull: pullCommand,
|
|
217
|
+
snapshot: snapshotCommand,
|
|
218
|
+
snapshots: snapshotsCommand,
|
|
219
|
+
restore: restoreCommand,
|
|
220
|
+
migrate: migrateCommand,
|
|
221
|
+
access: defineCommand({
|
|
222
|
+
meta: {
|
|
223
|
+
name: "access",
|
|
224
|
+
description: "Show or set what the app's own pages may do with the database (full | readonly | off)"
|
|
225
|
+
},
|
|
226
|
+
args: {
|
|
227
|
+
...projectFlagArgs,
|
|
228
|
+
level: {
|
|
229
|
+
type: "positional",
|
|
230
|
+
description: "full | readonly | off (omit to show)",
|
|
231
|
+
required: false
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
run: reportErrors("db access", async ({ args }) => {
|
|
235
|
+
const { slug } = resolveProject(args);
|
|
236
|
+
const wanted = args.level?.trim().toLowerCase();
|
|
237
|
+
if (!wanted) {
|
|
238
|
+
printAccess(slug, await apiRequest("GET", `/projects/${slug}/db-access`));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (wanted !== "full" && wanted !== "readonly" && wanted !== "off") {
|
|
242
|
+
console.error(`unknown level "${wanted}" — expected full, readonly or off.`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
printAccess(slug, await apiRequest("PUT", `/projects/${slug}/db-access`, { level: wanted }));
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
//#endregion
|
|
251
|
+
export { dbCommand };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-
|
|
2
|
-
import { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Btzr9_q7.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-
|
|
2
|
-
import { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui
|
|
3
|
-
import { r as readMigrationFiles } from "./migrations-
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
|
+
import { r as readMigrationFiles } from "./migrations-Btzr9_q7.mjs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
@@ -37,7 +37,7 @@ async function workerPlugin(flags, slug) {
|
|
|
37
37
|
console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
|
-
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-
|
|
40
|
+
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-CWU6XFe_.mjs");
|
|
41
41
|
const secrets = readDevVars(flags.dir);
|
|
42
42
|
const email = siteConfig().email;
|
|
43
43
|
const plugin = workerDevPlugin({
|
|
@@ -165,7 +165,7 @@ async function devRemote(flags, slug) {
|
|
|
165
165
|
async function devLocal(flags, slug) {
|
|
166
166
|
let dump;
|
|
167
167
|
if (flags.pull) {
|
|
168
|
-
const { fetchProjectDump } = await import("./local-
|
|
168
|
+
const { fetchProjectDump } = await import("./local-CNPBZ89U.mjs");
|
|
169
169
|
console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
|
|
170
170
|
dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
|
|
171
171
|
}
|
|
@@ -180,7 +180,7 @@ async function devLocal(flags, slug) {
|
|
|
180
180
|
const reason = err instanceof ApiError ? err.explain().why : String(err);
|
|
181
181
|
console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
|
|
182
182
|
}
|
|
183
|
-
const { createSandbox } = await import("./local-
|
|
183
|
+
const { createSandbox } = await import("./local-CNPBZ89U.mjs");
|
|
184
184
|
const sandbox = await createSandbox({
|
|
185
185
|
slug,
|
|
186
186
|
persistDir: join(flags.dir, ".canopy", "local"),
|
package/dist/docs/ci.md
CHANGED
|
@@ -9,7 +9,8 @@ can deploy that one project and nothing else.
|
|
|
9
9
|
|
|
10
10
|
## Create a token
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Only the project owner can create a deploy token (any collaborator can list
|
|
13
|
+
and revoke them). From inside the project directory:
|
|
13
14
|
|
|
14
15
|
```bash
|
|
15
16
|
canopy token create --name "GitHub Actions"
|
|
@@ -47,4 +48,10 @@ No `canopy login` step is needed — the token is the credential.
|
|
|
47
48
|
- A deploy token can only deploy its own project — it reaches that project's
|
|
48
49
|
deploy endpoints and upload grants and nothing else. If it strays to another
|
|
49
50
|
project's resources, the platform rejects it.
|
|
51
|
+
- The token belongs to the project, not to the person who created it. It keeps
|
|
52
|
+
working when they leave the project or the company; deploys it makes show up
|
|
53
|
+
in the history under the token's name.
|
|
54
|
+
- A token that goes 90 days without a deploy stops working. Active pipelines
|
|
55
|
+
never notice; a forgotten one dies on its own. The token list shows when each
|
|
56
|
+
was last used.
|
|
50
57
|
- Revoke a token the moment it might be exposed; create a fresh one to replace it.
|
package/dist/docs/cli-egress.md
CHANGED
|
@@ -32,7 +32,8 @@ target directory, or `--project <slug>` to override — and honor the global
|
|
|
32
32
|
- `mode [allowlist|open]` — show or switch the
|
|
33
33
|
[egress mode](https://docs.canopy.website/platform/egress#open-mode). Switching to `open` prints a risk
|
|
34
34
|
summary and asks you to type the project slug; pass `--accept-risk` to
|
|
35
|
-
acknowledge it non-interactively.
|
|
35
|
+
acknowledge it non-interactively. Only the project owner can switch to
|
|
36
|
+
`open`; switching back needs no ceremony and any member can do it.
|
|
36
37
|
- `log` — print the [outbound traffic log](https://docs.canopy.website/platform/egress#the-outbound-traffic-log):
|
|
37
38
|
hosts the worker actually fetched, with counts and last-seen times.
|
|
38
39
|
|
package/dist/docs/cli-token.md
CHANGED
|
@@ -17,15 +17,17 @@ canopy token revoke <id>
|
|
|
17
17
|
|
|
18
18
|
All subcommands resolve the project the usual way — `canopy.json` in the target directory, or `--project <slug>` to override — and honor the global `--profile <site>` / `CANOPY_PROFILE` for which site the token belongs to.
|
|
19
19
|
|
|
20
|
-
- `create` — mint a new deploy token for the project.
|
|
20
|
+
- `create` — mint a new deploy token for the project. Owner only: collaborators get a clear error and should ask the owner.
|
|
21
21
|
- `--name <label>` — a label shown in the dashboard, e.g. `"GitHub Actions"`. Optional but recommended so you can tell tokens apart later.
|
|
22
|
-
- `list` — show the project's deploy tokens: id, name, creation time,
|
|
22
|
+
- `list` — show the project's deploy tokens: id, name, creation time, who created them, and when each was last used. Token values are never shown here.
|
|
23
23
|
- `revoke <id>` — delete a token by its id (from `canopy token list`). Deploys using it stop working immediately.
|
|
24
24
|
|
|
25
25
|
## How it works
|
|
26
26
|
|
|
27
27
|
`create` prints the token plaintext **exactly once** — the platform stores only a hash, so copy it right away. The token is scoped to a single project on a single site: it can deploy that project and nothing else, which makes it safe to hand to a CI system.
|
|
28
28
|
|
|
29
|
+
The token belongs to the project rather than to whoever created it, so CI keeps working when that person leaves. It has no fixed expiry but stops working after 90 days without a deploy; `list` shows when each token was last used.
|
|
30
|
+
|
|
29
31
|
To use it in CI, put the value in the `CANOPY_DEPLOY_TOKEN` environment variable — the CLI checks it before the login token, so `canopy deploy` works on a runner with no `canopy login`. For GitHub Actions, add it as the repo secret `CANOPY_DEPLOY_TOKEN` (`gh secret set CANOPY_DEPLOY_TOKEN`, or Settings → Secrets); pushes to main then deploy via the project's `canopy-deploy.yml` workflow.
|
|
30
32
|
|
|
31
33
|
If a deploy fails with a rejected token, it was likely revoked — create a fresh one here or in the dashboard (Settings → Deploy tokens) and update the secret. Losing a token is never a problem: revoke the old id and mint a new one.
|
|
@@ -54,4 +54,13 @@ the app down for Monks outside the team while a client with the password
|
|
|
54
54
|
still gets in. That combination — "the client and the project team, nobody
|
|
55
55
|
else" — is a common setup during a review phase.
|
|
56
56
|
|
|
57
|
+
The password opens the app's data the same way it opens the app. Whatever a
|
|
58
|
+
page may do with the project database under **Browser access** on the Data
|
|
59
|
+
tab — read and write by default — a visitor who knows the password may do
|
|
60
|
+
too, from their own browser. The dashboard points this out wherever the two
|
|
61
|
+
settings meet. If the project has tables the client should not see or change,
|
|
62
|
+
name them `private_…` so only the worker can reach them, or set browser
|
|
63
|
+
access to read only or off. See
|
|
64
|
+
[Who can call this from the browser](https://docs.canopy.website/sdk/database#who-can-call-this-from-the-browser).
|
|
65
|
+
|
|
57
66
|
- [Members & access](https://docs.canopy.website/platform/members-and-access) — Who can view and who can manage a project, and how to tighten it.
|
|
@@ -102,9 +102,11 @@ allowlist:
|
|
|
102
102
|
Because open mode means _users of your app can decide where it connects_,
|
|
103
103
|
enabling it is a deliberate act: the dashboard asks you to type the project
|
|
104
104
|
slug under a plain-language risk summary, and the CLI wants an interactive
|
|
105
|
-
confirmation or the explicit `--accept-risk` flag.
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
confirmation or the explicit `--accept-risk` flag. Only the **project owner**
|
|
106
|
+
can switch open mode on; other members can still manage the allowlist and
|
|
107
|
+
switch back. Extra care applies if the project has
|
|
108
|
+
[client access](https://docs.canopy.website/platform/client-access) enabled — then untrusted visitors
|
|
109
|
+
drive the fetches.
|
|
108
110
|
|
|
109
111
|
Switching back is one command (`canopy egress mode allowlist`), and the
|
|
110
112
|
dashboard offers to **seed the allowlist from the observed hosts** so
|
|
@@ -60,6 +60,7 @@ canopy db pull # download a fresh SQL dump
|
|
|
60
60
|
canopy db snapshot # store a dump server-side
|
|
61
61
|
canopy db snapshots # list stored snapshots
|
|
62
62
|
canopy db restore # replace project data with a snapshot
|
|
63
|
+
canopy db access # what the app's own pages may do with the database
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
`canopy db pull` is also how you develop against realistic data: pull a dump,
|
|
@@ -122,5 +122,35 @@ per SDK instance, so calling it often is free.
|
|
|
122
122
|
> `db.sql()` only when you genuinely need SQL (aggregates, joins, bulk updates), and branch on
|
|
123
123
|
> `db.dialect()` whenever the syntax differs.
|
|
124
124
|
|
|
125
|
+
## Who can call this from the browser
|
|
126
|
+
|
|
127
|
+
Everything above works from a page as well as from your worker — that is the
|
|
128
|
+
point of `canopy.db`: a form, a guestbook or an internal tool needs no backend
|
|
129
|
+
at all. But once a project _has_ a worker with tables the page must not touch,
|
|
130
|
+
you want a say. Two things give you that, both on the project's **Data** tab in
|
|
131
|
+
the dashboard (or `canopy db access` from the terminal):
|
|
132
|
+
|
|
133
|
+
- **Browser access** — one setting for what the app's own pages may do with the
|
|
134
|
+
database. **Read and write** is the default and how every project starts.
|
|
135
|
+
**Read only** lets pages read collections and run SQL that only reads, so
|
|
136
|
+
writes have to come from the worker. **Off** means pages cannot touch the
|
|
137
|
+
database at all. Your worker, the CLI and the dashboard's data explorer keep
|
|
138
|
+
full access whatever you pick.
|
|
139
|
+
- **`private_` names** — a collection or table whose name starts with
|
|
140
|
+
`private_` is only reachable from the worker, at every level. Pages calling
|
|
141
|
+
`collection("private_notes")` get a 403. Raw SQL cannot be checked table by
|
|
142
|
+
table, so while your database holds any `private_` name, `db.sql()` from a
|
|
143
|
+
page is refused too; use collections there, or run the SQL in the worker.
|
|
144
|
+
|
|
145
|
+
Everyone who opens the app gets the same rule — colleagues signed in with
|
|
146
|
+
OneLogin and, if you turned on [client access](https://docs.canopy.website/platform/client-access),
|
|
147
|
+
visitors with the password. Read only protects your data from being changed,
|
|
148
|
+
not from being seen: anyone who can open the app can read every table that is
|
|
149
|
+
not `private_`. To hide data, name it `private_` or turn browser access off
|
|
150
|
+
and serve it through a worker route.
|
|
151
|
+
|
|
152
|
+
A refused call throws a `CanopyError` with status 403 and a one-line
|
|
153
|
+
explanation that names the setting, so the fix is never a guess.
|
|
154
|
+
|
|
125
155
|
Where the data lives, snapshots, and how to inspect it from the dashboard are
|
|
126
156
|
covered in [Project data](https://docs.canopy.website/platform/project-data).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
2
|
-
import { n as siteConfig } from "./site-
|
|
1
|
+
import { t as version } from "./package-DTm0EzxR.mjs";
|
|
2
|
+
import { n as siteConfig } from "./site-DM2J10CB.mjs";
|
|
3
3
|
import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
|
|
4
|
-
import { r as reportErrors } from "./ui
|
|
4
|
+
import { r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject } from "./site-
|
|
2
|
-
import { a as unwrap, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
5
|
//#region src/egress.ts
|
|
@@ -152,7 +152,10 @@ const egressCommand = defineCommand({
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
-
const updated = await apiRequest("PUT", `/projects/${slug}/egress`, { mode: wanted })
|
|
155
|
+
const updated = await withContext(apiRequest("PUT", `/projects/${slug}/egress`, { mode: wanted }), {
|
|
156
|
+
action: `switch ${slug} to ${wanted} egress mode`,
|
|
157
|
+
fixes: { owner_only: "Only the project owner can switch egress to open mode — ask them to run this command or flip it in the dashboard." }
|
|
158
|
+
});
|
|
156
159
|
if (wanted === "open") {
|
|
157
160
|
console.log(`Open egress mode is ON for ${slug} — running workers pick it up now.`);
|
|
158
161
|
console.log(`Switch back anytime with \`canopy egress mode allowlist\`.`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
2
|
-
import { a as projectDirArgs, c as resolveProject, d as tryResolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-
|
|
3
|
-
import { c as apiDownload, l as apiRequest, r as reportErrors, t as formatDateTime, u as apiUpload } from "./ui
|
|
1
|
+
import { t as version } from "./package-DTm0EzxR.mjs";
|
|
2
|
+
import { a as projectDirArgs, c as resolveProject, d as tryResolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
3
|
+
import { c as apiDownload, l as apiRequest, r as reportErrors, t as formatDateTime, u as apiUpload } from "./ui-CUNkis89.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { basename, extname, join } from "node:path";
|
|
6
6
|
import { readFile, writeFile } from "node:fs/promises";
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as version } from "./package-
|
|
3
|
-
import { r as siteHeaderLine, t as setInvocationProfile } from "./site-
|
|
2
|
+
import { t as version } from "./package-DTm0EzxR.mjs";
|
|
3
|
+
import { r as siteHeaderLine, t as setInvocationProfile } from "./site-DM2J10CB.mjs";
|
|
4
4
|
import { a as notifyOfUpdate, o as runRefresh } from "./version-check-CcsA1QjI.mjs";
|
|
5
5
|
import { defineCommand, runMain } from "citty";
|
|
6
6
|
//#region src/index.ts
|
|
@@ -60,30 +60,30 @@ const main = defineCommand({
|
|
|
60
60
|
description: "canopy — create and deploy vibecoded projects"
|
|
61
61
|
},
|
|
62
62
|
subCommands: {
|
|
63
|
-
login: () => import("./login-
|
|
64
|
-
whoami: () => import("./login-
|
|
65
|
-
logout: () => import("./login-
|
|
66
|
-
create: () => import("./projects-
|
|
67
|
-
init: () => import("./projects-
|
|
68
|
-
projects: () => import("./projects-
|
|
69
|
-
delete: () => import("./projects-
|
|
70
|
-
dev: () => import("./dev-
|
|
71
|
-
deploy: () => import("./deploy-
|
|
72
|
-
promote: () => import("./deploy-
|
|
73
|
-
staging: () => import("./staging-
|
|
74
|
-
rollback: () => import("./deploy-
|
|
75
|
-
download: () => import("./deploy-
|
|
76
|
-
token: () => import("./token-
|
|
77
|
-
egress: () => import("./egress-
|
|
78
|
-
schedule: () => import("./schedule-
|
|
79
|
-
feedback: () => import("./feedback-
|
|
80
|
-
db: () => import("./db-
|
|
81
|
-
usage: () => import("./usage-
|
|
82
|
-
open: () => import("./open-
|
|
83
|
-
docs: () => import("./docs-
|
|
84
|
-
skill: () => import("./skill-
|
|
85
|
-
upgrade: () => import("./upgrade-
|
|
86
|
-
logs: () => import("./logs-
|
|
63
|
+
login: () => import("./login-COZ7Xcm0.mjs").then((m) => m.loginCommand),
|
|
64
|
+
whoami: () => import("./login-COZ7Xcm0.mjs").then((m) => m.whoamiCommand),
|
|
65
|
+
logout: () => import("./login-COZ7Xcm0.mjs").then((m) => m.logoutCommand),
|
|
66
|
+
create: () => import("./projects-CLs6MEF7.mjs").then((m) => m.createCommand),
|
|
67
|
+
init: () => import("./projects-CLs6MEF7.mjs").then((m) => m.initCommand),
|
|
68
|
+
projects: () => import("./projects-CLs6MEF7.mjs").then((m) => m.projectsCommand),
|
|
69
|
+
delete: () => import("./projects-CLs6MEF7.mjs").then((m) => m.deleteCommand),
|
|
70
|
+
dev: () => import("./dev-C5BmSJo1.mjs").then((m) => m.devCommand),
|
|
71
|
+
deploy: () => import("./deploy-CYKiBnrg.mjs").then((m) => m.deployCommand),
|
|
72
|
+
promote: () => import("./deploy-CYKiBnrg.mjs").then((m) => m.promoteCommand),
|
|
73
|
+
staging: () => import("./staging-pZpdZuj_.mjs").then((m) => m.stagingCommand),
|
|
74
|
+
rollback: () => import("./deploy-CYKiBnrg.mjs").then((m) => m.rollbackCommand),
|
|
75
|
+
download: () => import("./deploy-CYKiBnrg.mjs").then((m) => m.downloadCommand),
|
|
76
|
+
token: () => import("./token-BKwGa10J.mjs").then((m) => m.tokenCommand),
|
|
77
|
+
egress: () => import("./egress-BjN-7vil.mjs").then((m) => m.egressCommand),
|
|
78
|
+
schedule: () => import("./schedule-B8zA8WlG.mjs").then((m) => m.scheduleCommand),
|
|
79
|
+
feedback: () => import("./feedback-Bnmi1Ukv.mjs").then((m) => m.feedbackCommand),
|
|
80
|
+
db: () => import("./db-jmYaum8F.mjs").then((m) => m.dbCommand),
|
|
81
|
+
usage: () => import("./usage-UmzU6sGp.mjs").then((m) => m.usageCommand),
|
|
82
|
+
open: () => import("./open-D2hH933K.mjs").then((m) => m.openCommand),
|
|
83
|
+
docs: () => import("./docs-BLWio_65.mjs").then((m) => m.docsCommand),
|
|
84
|
+
skill: () => import("./skill-Dt49jdUT.mjs").then((m) => m.skillCommand),
|
|
85
|
+
upgrade: () => import("./upgrade-Cf81feok.mjs").then((m) => m.upgradeCommand),
|
|
86
|
+
logs: () => import("./logs-B_YoflCq.mjs").then((m) => m.logsCommand)
|
|
87
87
|
}
|
|
88
88
|
});
|
|
89
89
|
if (process.argv[2] !== "upgrade") notifyOfUpdate(version);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { s as apiDataRequest } from "./ui
|
|
2
|
-
import { n as META_COLUMNS, r as blobRouter, t as dbRouter } from "./src-
|
|
3
|
-
import { n as rewriteApiPath } from "./dev-
|
|
1
|
+
import { s as apiDataRequest } from "./ui-CUNkis89.mjs";
|
|
2
|
+
import { n as META_COLUMNS, r as blobRouter, t as dbRouter } from "./src-BK0Hiwsn.mjs";
|
|
3
|
+
import { n as rewriteApiPath } from "./dev-DVUEJ80c.mjs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join, relative, sep } from "node:path";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { _ as writeConfigFile, f as clearAllProfileTokens, g as upsertProfileAuth, h as setActiveProfile, m as readConfigFile, n as siteConfig, p as listBakedProfiles, v as getBakedProfile, y as isProfileId } from "./site-
|
|
2
|
-
import { n as openBrowser, r as reportErrors } from "./ui
|
|
1
|
+
import { _ as writeConfigFile, f as clearAllProfileTokens, g as upsertProfileAuth, h as setActiveProfile, m as readConfigFile, n as siteConfig, p as listBakedProfiles, v as getBakedProfile, y as isProfileId } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { n as openBrowser, r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { hostname } from "node:os";
|
|
5
5
|
import * as p from "@clack/prompts";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as resolveProject, o as projectFlagArgs } from "./site-
|
|
2
|
-
import { d as withContext, l as apiRequest, r as reportErrors } from "./ui
|
|
1
|
+
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { d as withContext, l as apiRequest, r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
//#region src/logs.ts
|
|
5
5
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { u as tryReadCanopyJson } from "./site-
|
|
2
|
-
import { s as apiDataRequest } from "./ui
|
|
1
|
+
import { u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { s as apiDataRequest } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
//#region src/migrations.ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, s as projectUrl } from "./site-
|
|
2
|
-
import { n as openBrowser, r as reportErrors } from "./ui
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, s as projectUrl } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { n as openBrowser, r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
//#region src/open.ts
|
|
5
5
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-
|
|
2
|
-
import { a as unwrap, i as requireTty, l as apiRequest, r as reportErrors } from "./ui
|
|
1
|
+
import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { a as unwrap, i as requireTty, l as apiRequest, r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, relative, resolve, sep } from "node:path";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as resolveProject, o as projectFlagArgs } from "./site-
|
|
2
|
-
import { d as withContext, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui
|
|
1
|
+
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { d as withContext, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
//#region src/schedule.ts
|
|
5
5
|
/**
|
|
@@ -170,7 +170,7 @@ function clearAllProfileTokens() {
|
|
|
170
170
|
function projectUrl(slug) {
|
|
171
171
|
const config = siteConfig();
|
|
172
172
|
const apex = config.projectDomain?.trim();
|
|
173
|
-
if (apex) return `${config.projectProtocol ??
|
|
173
|
+
if (apex) return `${config.projectProtocol ?? "https"}://${slug}.${apex.replace(/^\./, "")}`;
|
|
174
174
|
const apiHost = new URL(config.apiUrl).hostname;
|
|
175
175
|
if (apiHost === "run.app" || apiHost.endsWith(".run.app") || apiHost.startsWith("api.")) throw new Error("Cannot derive the project URL. Set projectDomain (baked profile or CANOPY_PROJECT_DOMAIN).");
|
|
176
176
|
return `https://${slug}.${apiHost.split(".").slice(-2).join(".")}`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
2
|
-
import { r as reportErrors } from "./ui
|
|
1
|
+
import { t as version } from "./package-DTm0EzxR.mjs";
|
|
2
|
+
import { r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
@@ -384,8 +384,21 @@ function dbRouter(config) {
|
|
|
384
384
|
error: "invalid_query",
|
|
385
385
|
detail: "expected { query: string, params?: (string|number|boolean|null)[], readonly?: boolean }"
|
|
386
386
|
}, 400);
|
|
387
|
+
const verdict = await config.guard?.(c, {
|
|
388
|
+
kind: "sql",
|
|
389
|
+
readonly: body.readonly
|
|
390
|
+
}, resolved);
|
|
391
|
+
if (verdict instanceof Response) return verdict;
|
|
392
|
+
const forced = verdict?.readonly === true;
|
|
393
|
+
if (forced) {
|
|
394
|
+
const violation = readOnlySqlViolation(body.query);
|
|
395
|
+
if (violation) return c.json({
|
|
396
|
+
error: "browser_db_access",
|
|
397
|
+
detail: `${violation}. ${verdict.refusal}`
|
|
398
|
+
}, 403);
|
|
399
|
+
}
|
|
387
400
|
if (body.readonly === void 0) {
|
|
388
|
-
const result = await store.query(body.query, params).catch((err) => err);
|
|
401
|
+
const result = await store.query(body.query, params, forced ? { readonly: true } : void 0).catch((err) => err);
|
|
389
402
|
if (result instanceof Error) return c.json({
|
|
390
403
|
error: "sql_error",
|
|
391
404
|
detail: result.message
|
|
@@ -399,7 +412,7 @@ function dbRouter(config) {
|
|
|
399
412
|
detail: violation
|
|
400
413
|
}, 400);
|
|
401
414
|
}
|
|
402
|
-
const result = await store.query(body.query, params, { readonly: body.readonly }).catch((err) => err);
|
|
415
|
+
const result = await store.query(body.query, params, { readonly: body.readonly || forced }).catch((err) => err);
|
|
403
416
|
if (result instanceof Error) return c.json({
|
|
404
417
|
error: "sql_error",
|
|
405
418
|
detail: result.message
|
|
@@ -416,6 +429,8 @@ function dbRouter(config) {
|
|
|
416
429
|
db.get("/db/schema", async (c) => {
|
|
417
430
|
const resolved = await resolve(c);
|
|
418
431
|
if (resolved instanceof Response) return resolved;
|
|
432
|
+
const verdict = await config.guard?.(c, { kind: "schema" }, resolved);
|
|
433
|
+
if (verdict instanceof Response) return verdict;
|
|
419
434
|
const schema = await introspectSchema(resolved.store, resolved.dialect.name).catch((err) => err);
|
|
420
435
|
if (schema instanceof Error) return c.json({
|
|
421
436
|
error: "sql_error",
|
|
@@ -429,6 +444,11 @@ function dbRouter(config) {
|
|
|
429
444
|
const { store, dialect } = resolved;
|
|
430
445
|
const collection = c.req.param("collection");
|
|
431
446
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
447
|
+
const verdict = await config.guard?.(c, {
|
|
448
|
+
kind: "write",
|
|
449
|
+
collection
|
|
450
|
+
}, resolved);
|
|
451
|
+
if (verdict instanceof Response) return verdict;
|
|
432
452
|
const body = await c.req.json().catch(() => void 0);
|
|
433
453
|
if (!isPlainObject(body)) return c.json({
|
|
434
454
|
error: "invalid_document",
|
|
@@ -457,6 +477,11 @@ function dbRouter(config) {
|
|
|
457
477
|
const { store, dialect } = resolved;
|
|
458
478
|
const collection = c.req.param("collection");
|
|
459
479
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
480
|
+
const verdict = await config.guard?.(c, {
|
|
481
|
+
kind: "read",
|
|
482
|
+
collection
|
|
483
|
+
}, resolved);
|
|
484
|
+
if (verdict instanceof Response) return verdict;
|
|
460
485
|
const q = c.req.query();
|
|
461
486
|
try {
|
|
462
487
|
const limit = intParam(q.limit, "limit", 100, MAX_LIMIT);
|
|
@@ -509,6 +534,11 @@ function dbRouter(config) {
|
|
|
509
534
|
const { store, dialect } = resolved;
|
|
510
535
|
const collection = c.req.param("collection");
|
|
511
536
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
537
|
+
const verdict = await config.guard?.(c, {
|
|
538
|
+
kind: "read",
|
|
539
|
+
collection
|
|
540
|
+
}, resolved);
|
|
541
|
+
if (verdict instanceof Response) return verdict;
|
|
512
542
|
const get = dialect.get(collection, c.req.param("id"));
|
|
513
543
|
const row = (await store.query(get.sql, get.params)).results[0];
|
|
514
544
|
return c.json(row ? toDoc(row) : null);
|
|
@@ -519,6 +549,11 @@ function dbRouter(config) {
|
|
|
519
549
|
const { store, dialect } = resolved;
|
|
520
550
|
const collection = c.req.param("collection");
|
|
521
551
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
552
|
+
const verdict = await config.guard?.(c, {
|
|
553
|
+
kind: "write",
|
|
554
|
+
collection
|
|
555
|
+
}, resolved);
|
|
556
|
+
if (verdict instanceof Response) return verdict;
|
|
522
557
|
const body = await c.req.json().catch(() => void 0);
|
|
523
558
|
if (!isPlainObject(body)) return c.json({
|
|
524
559
|
error: "invalid_patch",
|
|
@@ -535,6 +570,11 @@ function dbRouter(config) {
|
|
|
535
570
|
const { store, dialect } = resolved;
|
|
536
571
|
const collection = c.req.param("collection");
|
|
537
572
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
573
|
+
const verdict = await config.guard?.(c, {
|
|
574
|
+
kind: "write",
|
|
575
|
+
collection
|
|
576
|
+
}, resolved);
|
|
577
|
+
if (verdict instanceof Response) return verdict;
|
|
538
578
|
const del = dialect.del(collection, c.req.param("id"));
|
|
539
579
|
const result = await store.query(del.sql, del.params);
|
|
540
580
|
return c.json({ deleted: (result.meta.changes ?? 0) > 0 });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } from "./site-
|
|
2
|
-
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui
|
|
1
|
+
import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
5
|
//#region src/staging.ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-
|
|
2
|
-
import { l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
const tokenCommand = defineCommand({
|
|
5
5
|
meta: {
|
|
@@ -21,9 +21,10 @@ const tokenCommand = defineCommand({
|
|
|
21
21
|
},
|
|
22
22
|
run: reportErrors("token create", async ({ args }) => {
|
|
23
23
|
const { slug } = resolveProject(args);
|
|
24
|
-
const { token } = await apiRequest("POST", `/projects/${slug}/deploy-tokens`, { name: args.name });
|
|
24
|
+
const { token, idle_days } = await apiRequest("POST", `/projects/${slug}/deploy-tokens`, { name: args.name });
|
|
25
25
|
console.log(`Deploy token for ${slug} (shown once — store it now):\n`);
|
|
26
26
|
console.log(` ${token}\n`);
|
|
27
|
+
console.log(`It belongs to the project and stops working after ${idle_days} days without a deploy.`);
|
|
27
28
|
console.log("Add it to the GitHub repo as secret CANOPY_DEPLOY_TOKEN");
|
|
28
29
|
console.log("(gh secret set CANOPY_DEPLOY_TOKEN, or repo Settings → Secrets).");
|
|
29
30
|
console.log("Pushes to main then deploy via .github/workflows/canopy-deploy.yml.");
|
|
@@ -42,7 +43,10 @@ const tokenCommand = defineCommand({
|
|
|
42
43
|
console.log(`No deploy tokens for ${slug} — create one with \`canopy token create\`.`);
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
45
|
-
for (const t of tokens)
|
|
46
|
+
for (const t of tokens) {
|
|
47
|
+
const used = t.last_used_at ? `last used ${formatDateTime(t.last_used_at)}` : "never used";
|
|
48
|
+
console.log(`${t.id} ${t.name ?? "(unnamed)"} created ${formatDateTime(t.created_at)} by ${t.created_by ?? "?"} ${used}`);
|
|
49
|
+
}
|
|
46
50
|
})
|
|
47
51
|
}),
|
|
48
52
|
revoke: defineCommand({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as PACKAGE_NAME } from "./version-check-CcsA1QjI.mjs";
|
|
2
|
-
import { r as reportErrors } from "./ui
|
|
2
|
+
import { r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -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-Dt49jdUT.mjs");
|
|
116
116
|
const { homedir } = await import("node:os");
|
|
117
117
|
const refreshGlobal = skillInstalled(homedir());
|
|
118
118
|
const refreshRepo = process.cwd() !== homedir() && skillInstalled(process.cwd());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as resolveProject, o as projectFlagArgs } from "./site-
|
|
2
|
-
import { d as withContext, l as apiRequest, r as reportErrors } from "./ui
|
|
1
|
+
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { d as withContext, l as apiRequest, r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
//#region src/usage.ts
|
|
5
5
|
/**
|
package/package.json
CHANGED
package/dist/db-TaSLpQZ5.mjs
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import { c as resolveProject, o as projectFlagArgs } from "./site-BFaWfSVu.mjs";
|
|
2
|
-
import { a as unwrap, d as withContext, i as requireTty, r as reportErrors, s as apiDataRequest } from "./ui--atbZ-TM.mjs";
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Bg9FEGFh.mjs";
|
|
4
|
-
import { defineCommand } from "citty";
|
|
5
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
-
import * as p from "@clack/prompts";
|
|
7
|
-
//#region src/db.ts
|
|
8
|
-
/**
|
|
9
|
-
* canopy db pull | snapshot | snapshots | restore — data safety from the
|
|
10
|
-
* terminal (docs/04-roadmap.md Phase 2 item 4, pulled forward).
|
|
11
|
-
*
|
|
12
|
-
* pull download a fresh SQL dump of the project database
|
|
13
|
-
* snapshot store a dump server-side (restorable from CLI or dashboard)
|
|
14
|
-
* snapshots list stored snapshots
|
|
15
|
-
* restore replace ALL project data from a snapshot or a local .sql file
|
|
16
|
-
* migrate apply pending schema migrations (docs/13-migrations-and-drizzle.md)
|
|
17
|
-
*
|
|
18
|
-
* The project comes from canopy.json in the current directory, or
|
|
19
|
-
* --project <slug>.
|
|
20
|
-
*/
|
|
21
|
-
/** Shared flag: operate on the staging channel's database (docs/14). */
|
|
22
|
-
const stagingFlagArgs = { staging: {
|
|
23
|
-
type: "boolean",
|
|
24
|
-
default: false,
|
|
25
|
-
description: "Target the staging channel's database instead of live"
|
|
26
|
-
} };
|
|
27
|
-
const channelOf = (args) => args.staging ? "staging" : "live";
|
|
28
|
-
const channelNote = (args) => args.staging ? " [staging]" : "";
|
|
29
|
-
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
30
|
-
const dbCommand = defineCommand({
|
|
31
|
-
meta: {
|
|
32
|
-
name: "db",
|
|
33
|
-
description: "Data tools: pull, snapshot, snapshots, restore, migrate"
|
|
34
|
-
},
|
|
35
|
-
subCommands: {
|
|
36
|
-
pull: defineCommand({
|
|
37
|
-
meta: {
|
|
38
|
-
name: "pull",
|
|
39
|
-
description: "Download a fresh SQL dump of the project database"
|
|
40
|
-
},
|
|
41
|
-
args: {
|
|
42
|
-
...projectFlagArgs,
|
|
43
|
-
...stagingFlagArgs,
|
|
44
|
-
out: {
|
|
45
|
-
type: "string",
|
|
46
|
-
description: "Output file (defaults to <slug>-<timestamp>.sql)"
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
run: reportErrors("db pull", async ({ args }) => {
|
|
50
|
-
const { slug } = resolveProject(args);
|
|
51
|
-
const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
|
|
52
|
-
const spin = p.spinner();
|
|
53
|
-
spin.start(`Exporting ${slug}${channelNote(args)}…`);
|
|
54
|
-
try {
|
|
55
|
-
const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channelOf(args));
|
|
56
|
-
const download = await fetch(url);
|
|
57
|
-
if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
|
|
58
|
-
const sql = Buffer.from(await download.arrayBuffer());
|
|
59
|
-
writeFileSync(out, sql);
|
|
60
|
-
spin.stop(`Wrote ${out} (${formatSize(sql.byteLength)}).`);
|
|
61
|
-
} catch (err) {
|
|
62
|
-
spin.error("Export failed.");
|
|
63
|
-
throw err;
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
}),
|
|
67
|
-
snapshot: defineCommand({
|
|
68
|
-
meta: {
|
|
69
|
-
name: "snapshot",
|
|
70
|
-
description: "Store a dump server-side (restorable anytime)"
|
|
71
|
-
},
|
|
72
|
-
args: {
|
|
73
|
-
...projectFlagArgs,
|
|
74
|
-
...stagingFlagArgs
|
|
75
|
-
},
|
|
76
|
-
run: reportErrors("db snapshot", async ({ args }) => {
|
|
77
|
-
const { slug } = resolveProject(args);
|
|
78
|
-
const spin = p.spinner();
|
|
79
|
-
spin.start(`Snapshotting ${slug}${channelNote(args)}…`);
|
|
80
|
-
let created;
|
|
81
|
-
try {
|
|
82
|
-
({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug, void 0, void 0, channelOf(args)));
|
|
83
|
-
} catch (err) {
|
|
84
|
-
spin.error("Snapshot failed.");
|
|
85
|
-
throw err;
|
|
86
|
-
}
|
|
87
|
-
spin.stop(`Stored ${created.key} (${formatSize(created.size)}).`);
|
|
88
|
-
console.log(`Restore anytime with \`canopy db restore --snapshot ${created.key}\`.`);
|
|
89
|
-
})
|
|
90
|
-
}),
|
|
91
|
-
snapshots: defineCommand({
|
|
92
|
-
meta: {
|
|
93
|
-
name: "snapshots",
|
|
94
|
-
description: "List stored snapshots"
|
|
95
|
-
},
|
|
96
|
-
args: {
|
|
97
|
-
...projectFlagArgs,
|
|
98
|
-
...stagingFlagArgs
|
|
99
|
-
},
|
|
100
|
-
run: reportErrors("db snapshots", async ({ args }) => {
|
|
101
|
-
const { slug } = resolveProject(args);
|
|
102
|
-
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
|
|
103
|
-
if (snapshots.length === 0) {
|
|
104
|
-
console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
for (const snapshot of snapshots) console.log(`${snapshot.key} ${formatSize(snapshot.size).padStart(9)} ${snapshot.createdAt}`);
|
|
108
|
-
})
|
|
109
|
-
}),
|
|
110
|
-
restore: defineCommand({
|
|
111
|
-
meta: {
|
|
112
|
-
name: "restore",
|
|
113
|
-
description: "Replace ALL project data from a snapshot or a local .sql file"
|
|
114
|
-
},
|
|
115
|
-
args: {
|
|
116
|
-
...projectFlagArgs,
|
|
117
|
-
...stagingFlagArgs,
|
|
118
|
-
file: {
|
|
119
|
-
type: "positional",
|
|
120
|
-
description: "Local .sql dump to restore from",
|
|
121
|
-
required: false
|
|
122
|
-
},
|
|
123
|
-
snapshot: {
|
|
124
|
-
type: "string",
|
|
125
|
-
description: "Server-side snapshot key to restore from"
|
|
126
|
-
},
|
|
127
|
-
yes: {
|
|
128
|
-
type: "boolean",
|
|
129
|
-
default: false,
|
|
130
|
-
description: "Skip the type-the-slug confirmation"
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
run: reportErrors("db restore", async ({ args }) => {
|
|
134
|
-
const { slug } = resolveProject(args);
|
|
135
|
-
const { file } = args;
|
|
136
|
-
let snapshotKey = args.snapshot;
|
|
137
|
-
if (!snapshotKey && !file) {
|
|
138
|
-
requireTty("pass a local .sql dump or --snapshot <key>.");
|
|
139
|
-
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
|
|
140
|
-
if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
|
|
141
|
-
snapshotKey = unwrap(await p.select({
|
|
142
|
-
message: `Snapshot to restore into ${slug}`,
|
|
143
|
-
options: snapshots.map((snapshot) => ({
|
|
144
|
-
value: snapshot.key,
|
|
145
|
-
label: snapshot.key,
|
|
146
|
-
hint: `${formatSize(snapshot.size)} · ${snapshot.createdAt}`
|
|
147
|
-
}))
|
|
148
|
-
}));
|
|
149
|
-
}
|
|
150
|
-
if (!args.yes) {
|
|
151
|
-
requireTty("pass --yes to restore without confirmation.");
|
|
152
|
-
if (unwrap(await p.text({ message: `Restore will REPLACE all current data in "${slug}"${channelNote(args)} with ${snapshotKey ?? file}. Type the slug to confirm.` })).trim() !== slug) {
|
|
153
|
-
p.cancel("Aborted.");
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
|
|
158
|
-
if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }, void 0, channelOf(args)), restoreContext);
|
|
159
|
-
else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql", channelOf(args)), restoreContext);
|
|
160
|
-
p.log.success(`Restored ${slug}${channelNote(args)}.`);
|
|
161
|
-
})
|
|
162
|
-
}),
|
|
163
|
-
migrate: defineCommand({
|
|
164
|
-
meta: {
|
|
165
|
-
name: "migrate",
|
|
166
|
-
description: "Apply pending schema migrations to the project database"
|
|
167
|
-
},
|
|
168
|
-
args: {
|
|
169
|
-
...projectFlagArgs,
|
|
170
|
-
...stagingFlagArgs,
|
|
171
|
-
plan: {
|
|
172
|
-
type: "boolean",
|
|
173
|
-
default: false,
|
|
174
|
-
description: "Dry run: list pending migrations without applying anything"
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
run: reportErrors("db migrate", async ({ args }) => {
|
|
178
|
-
const { dir, slug } = resolveProject(args);
|
|
179
|
-
const files = readMigrationFiles(dir);
|
|
180
|
-
if (files === void 0) throw new Error("No migrations directory configured — set \"migrations\" in canopy.json (e.g. {\"migrations\": \"./migrations\"}, pointing at your drizzle-kit output).");
|
|
181
|
-
if (args.plan) {
|
|
182
|
-
const result = await withContext(migrateProject(slug, files, true, channelOf(args)), { action: `plan migrations for "${slug}"${channelNote(args)}` });
|
|
183
|
-
if (result.pending.length === 0) console.log(`Nothing pending (${result.applied.length} applied previously).`);
|
|
184
|
-
else for (const name of result.pending) console.log(`pending ${name}`);
|
|
185
|
-
for (const line of describeMigrateResult(result)) console.log(line);
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
const spin = p.spinner();
|
|
189
|
-
spin.start(`Migrating ${slug}${channelNote(args)}…`);
|
|
190
|
-
let result;
|
|
191
|
-
try {
|
|
192
|
-
result = await withContext(migrateProject(slug, files, false, channelOf(args)), { action: `migrate "${slug}"${channelNote(args)}` });
|
|
193
|
-
} catch (err) {
|
|
194
|
-
spin.error("Migrate failed.");
|
|
195
|
-
throw err;
|
|
196
|
-
}
|
|
197
|
-
spin.stop(result.appliedNow.length === 0 ? `Nothing pending for ${slug}.` : `Applied ${result.appliedNow.length} migration(s) to ${slug}.`);
|
|
198
|
-
for (const line of describeMigrateResult(result)) console.log(` ${line}`);
|
|
199
|
-
})
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
//#endregion
|
|
204
|
-
export { dbCommand };
|
package/dist/dev-BFPlhkzp.mjs
DELETED