castle-web-cli 0.4.109 → 0.4.111
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/agent-failures.d.ts +1 -1
- package/dist/agent-failures.js +2 -0
- package/dist/agent-prompts.js +1 -0
- package/dist/agent.js +69 -10
- package/dist/api.d.ts +24 -5
- package/dist/api.js +71 -9
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +9 -1
- package/dist/httpJson.d.ts +4 -0
- package/dist/httpJson.js +21 -0
- package/dist/ide.d.ts +2 -0
- package/dist/ide.js +34 -15
- package/dist/importBrowse.d.ts +21 -0
- package/dist/importBrowse.js +240 -0
- package/dist/imports.d.ts +21 -1
- package/dist/imports.js +96 -24
- package/dist/index.js +11 -4
- package/dist/list-decks.d.ts +3 -0
- package/dist/list-decks.js +44 -0
- package/dist/metering.d.ts +1 -0
- package/dist/metering.js +3 -0
- package/dist/save-deck.d.ts +3 -1
- package/dist/save-deck.js +7 -3
- package/dist/serve.js +10 -2
- package/dist/shell/assets/index-BbQN3p8P.css +1 -0
- package/dist/shell/assets/index-DIVtkGt8.js +144 -0
- package/dist/shell/favicon-16x16.png +0 -0
- package/dist/shell/favicon-32x32.png +0 -0
- package/dist/shell/favicon.ico +0 -0
- package/dist/shell/index.html +8 -2
- package/package.json +2 -2
- package/dist/shell/assets/index-CYG9z07_.js +0 -144
- package/dist/shell/assets/index-wU4ol--C.css +0 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// The editor's Import picker, server side: `/__castle/import/*` on the serve.
|
|
2
|
+
// Answers "which decks could this person import" and then does the import.
|
|
3
|
+
//
|
|
4
|
+
// It runs the GraphQL with the CLI's own login (`config.getToken()`), which is
|
|
5
|
+
// what a local serve has. In the cloud the shell will instead ask its castle-www
|
|
6
|
+
// host over postMessage, so the sandbox never holds the token -- that path is
|
|
7
|
+
// deliberately not built yet, and this one is what the local editor uses either
|
|
8
|
+
// way.
|
|
9
|
+
//
|
|
10
|
+
// Nothing here goes through the deck iframe or the kit: the Files panel that
|
|
11
|
+
// opens this picker is a builtin shell panel, and it has to keep working on a
|
|
12
|
+
// `--kit none` deck that ships no editor app at all.
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as os from "os";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
import { nanoid } from "nanoid";
|
|
17
|
+
import * as api from "./api.js";
|
|
18
|
+
import * as config from "./config.js";
|
|
19
|
+
import { runTar } from "./save-deck.js";
|
|
20
|
+
import { readCastleJson } from "./castleJson.js";
|
|
21
|
+
import { ImportError, addImportTo, importedDeckIds, parseDeckRef } from "./imports.js";
|
|
22
|
+
import { errorMessage, readRequestBody, sendJson } from "./httpJson.js";
|
|
23
|
+
export const IMPORT_API_PREFIX = "/__castle/import/";
|
|
24
|
+
// Rows per list, and how many decks we are willing to ask the source question
|
|
25
|
+
// about to fill one. An account can hold hundreds of decks and only some are
|
|
26
|
+
// web decks, so the scan has to look past the first fifty -- but not forever.
|
|
27
|
+
const LIST_LIMIT = 50;
|
|
28
|
+
const SCAN_CAP = 200;
|
|
29
|
+
const SOURCE_CHUNK = 100;
|
|
30
|
+
// Playlists read for the Saved list. Flattened together with bookmarks, so a
|
|
31
|
+
// handful is already more decks than the list shows.
|
|
32
|
+
const PLAYLIST_CAP = 10;
|
|
33
|
+
// The bookmarks feed already covers this playlist; listing both would just
|
|
34
|
+
// duplicate it.
|
|
35
|
+
const BOOKMARKS_PLAYLIST_PREFIX = "bookmarks-";
|
|
36
|
+
// The kits list, hardcoded. A playlist can't hold it: playlist feeds drop
|
|
37
|
+
// unlisted decks, and a kit is published unlisted. Adding a kit means shipping
|
|
38
|
+
// an update to this list.
|
|
39
|
+
const KIT_DECK_IDS = [
|
|
40
|
+
"ckRZGFW4iPrx", // physics-2d
|
|
41
|
+
];
|
|
42
|
+
// Listing is several server round trips, and the picker is opened, closed and
|
|
43
|
+
// reopened while someone decides. Cached briefly so that costs once.
|
|
44
|
+
const LIST_TTL_MS = 60_000;
|
|
45
|
+
const listCache = new Map();
|
|
46
|
+
function asTab(value) {
|
|
47
|
+
return value === "saved" || value === "kits" ? value : "mine";
|
|
48
|
+
}
|
|
49
|
+
function toCandidate(row, hasSource) {
|
|
50
|
+
return {
|
|
51
|
+
deckId: row.deckId,
|
|
52
|
+
title: row.title?.trim() ? row.title.trim() : "Untitled",
|
|
53
|
+
creator: row.creator?.username ?? null,
|
|
54
|
+
imageUrl: row.initialCard?.backgroundImage?.smallUrl ?? null,
|
|
55
|
+
parentCreator: row.parentDeck?.creator?.username ?? null,
|
|
56
|
+
hasSource,
|
|
57
|
+
imported: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Which of these the deck already has. Stamped on the way OUT rather than
|
|
61
|
+
// baked into the cached list: the pins change while the picker is open (an
|
|
62
|
+
// import from here, an `add-import` in the terminal), and re-reading one file
|
|
63
|
+
// is cheaper than throwing the list away.
|
|
64
|
+
function stampImported(deckDir, decks) {
|
|
65
|
+
const already = importedDeckIds(deckDir);
|
|
66
|
+
return decks.map((deck) => ({ ...deck, imported: already.has(deck.deckId) }));
|
|
67
|
+
}
|
|
68
|
+
// First occurrence of each deck wins, and the deck being edited never appears:
|
|
69
|
+
// a deck can't import itself, so offering it is offering an error.
|
|
70
|
+
function dedupe(rows, currentDeckId) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
if (!row?.deckId || row.deckId === currentDeckId || seen.has(row.deckId))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(row.deckId);
|
|
77
|
+
out.push(row);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
// Keep the decks that have source on the server -- having source IS being
|
|
82
|
+
// importable -- taking them in the order they arrived until the list is full.
|
|
83
|
+
// Asks in chunks so one bad batch doesn't cost the whole scan.
|
|
84
|
+
async function filterImportable(rows) {
|
|
85
|
+
const decks = [];
|
|
86
|
+
let scanned = 0;
|
|
87
|
+
while (scanned < rows.length && scanned < SCAN_CAP && decks.length < LIST_LIMIT) {
|
|
88
|
+
const chunk = rows.slice(scanned, scanned + SOURCE_CHUNK);
|
|
89
|
+
scanned += chunk.length;
|
|
90
|
+
const versions = await api.webDeckSourceVersions(chunk.map((r) => r.deckId));
|
|
91
|
+
for (const row of chunk) {
|
|
92
|
+
if (decks.length >= LIST_LIMIT)
|
|
93
|
+
break;
|
|
94
|
+
if (versions.has(row.deckId))
|
|
95
|
+
decks.push(toCandidate(row, true));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { signedIn: true, decks, truncated: scanned < rows.length };
|
|
99
|
+
}
|
|
100
|
+
// Bookmarks plus the user's playlists, flattened. Both are "decks I kept", and
|
|
101
|
+
// which of the two something landed in is not a distinction worth a tab.
|
|
102
|
+
async function savedRows() {
|
|
103
|
+
const userId = config.getUserId();
|
|
104
|
+
const bookmarks = await api.feedDecks("bookmarks", LIST_LIMIT);
|
|
105
|
+
if (!userId)
|
|
106
|
+
return bookmarks;
|
|
107
|
+
const playlists = (await api.myPlaylists(userId, PLAYLIST_CAP * 2))
|
|
108
|
+
.filter((p) => !p.playlistId.startsWith(BOOKMARKS_PLAYLIST_PREFIX))
|
|
109
|
+
.slice(0, PLAYLIST_CAP);
|
|
110
|
+
const lists = await Promise.all(playlists.map((p) => api.feedDecks(`playlist:${p.playlistId}`, LIST_LIMIT).catch(() => [])));
|
|
111
|
+
return [bookmarks, ...lists].flat();
|
|
112
|
+
}
|
|
113
|
+
async function tabRows(tab) {
|
|
114
|
+
if (tab === "saved")
|
|
115
|
+
return savedRows();
|
|
116
|
+
if (tab === "kits")
|
|
117
|
+
return api.deckRows(KIT_DECK_IDS);
|
|
118
|
+
return api.myDecks();
|
|
119
|
+
}
|
|
120
|
+
const EMPTY_LIST = { signedIn: false, decks: [], truncated: false };
|
|
121
|
+
export async function listImportable(deckDir, tab) {
|
|
122
|
+
if (!config.getToken())
|
|
123
|
+
return EMPTY_LIST;
|
|
124
|
+
const key = `${deckDir}|${tab}`;
|
|
125
|
+
const cached = listCache.get(key);
|
|
126
|
+
const value = cached && Date.now() - cached.at < LIST_TTL_MS ? cached.value : null;
|
|
127
|
+
if (value)
|
|
128
|
+
return { ...value, decks: stampImported(deckDir, value.decks) };
|
|
129
|
+
const currentDeckId = readCastleJson(deckDir)?.deckId ?? null;
|
|
130
|
+
const fresh = await filterImportable(dedupe(await tabRows(tab), currentDeckId));
|
|
131
|
+
listCache.set(key, { at: Date.now(), value: fresh });
|
|
132
|
+
return { ...fresh, decks: stampImported(deckDir, fresh.decks) };
|
|
133
|
+
}
|
|
134
|
+
// A deck someone pasted a link (or an id) to. Unlike the lists, this one does
|
|
135
|
+
// NOT hide a deck with no source -- naming a specific deck and getting nothing
|
|
136
|
+
// back says less than naming it and being told it was never saved.
|
|
137
|
+
export async function resolveDeckRef(deckDir, ref) {
|
|
138
|
+
const deckId = parseDeckRef(ref);
|
|
139
|
+
if (!deckId) {
|
|
140
|
+
throw new ImportError("bad-deck-ref", `Not a deck id or a castle deck link: ${ref}`);
|
|
141
|
+
}
|
|
142
|
+
if (readCastleJson(deckDir)?.deckId === deckId) {
|
|
143
|
+
throw new ImportError("self-import", `That is this deck -- a deck can't import itself.`);
|
|
144
|
+
}
|
|
145
|
+
const [rows, source] = await Promise.all([
|
|
146
|
+
api.deckRows([deckId]),
|
|
147
|
+
api.webDeckSource(deckId).catch(() => null),
|
|
148
|
+
]);
|
|
149
|
+
const row = rows[0];
|
|
150
|
+
if (!row)
|
|
151
|
+
throw new ImportError("bad-deck-ref", `No deck ${deckId} on the server.`);
|
|
152
|
+
return stampImported(deckDir, [toCandidate(row, source !== null)])[0];
|
|
153
|
+
}
|
|
154
|
+
// How many files a deck's source holds. Not on any list query, so it is counted
|
|
155
|
+
// from the archive itself -- which means downloading it, which is why the picker
|
|
156
|
+
// asks only about the one deck someone has selected rather than every row.
|
|
157
|
+
// Keyed by version, so re-selecting a row is free until its deck is saved again.
|
|
158
|
+
const fileCountCache = new Map();
|
|
159
|
+
export async function sourceFileCount(deckId) {
|
|
160
|
+
if (!api.isDeckIdShaped(deckId))
|
|
161
|
+
return null;
|
|
162
|
+
const source = await api.webDeckSource(deckId).catch(() => null);
|
|
163
|
+
if (!source)
|
|
164
|
+
return null;
|
|
165
|
+
const key = `${deckId}@${source.updatedAt}`;
|
|
166
|
+
const cached = fileCountCache.get(key);
|
|
167
|
+
if (cached !== undefined)
|
|
168
|
+
return cached;
|
|
169
|
+
const tmpFile = path.join(os.tmpdir(), `castle-import-count-${nanoid(8)}.tar.gz`);
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(30000) });
|
|
172
|
+
if (!res.ok)
|
|
173
|
+
return null;
|
|
174
|
+
fs.writeFileSync(tmpFile, Buffer.from(await res.arrayBuffer()));
|
|
175
|
+
const listing = await runTar(["-tzf", tmpFile], { capture: true });
|
|
176
|
+
const count = listing.split("\n").filter((line) => line && !line.endsWith("/")).length;
|
|
177
|
+
fileCountCache.set(key, count);
|
|
178
|
+
return count;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null; // an unreadable archive just means no count to show
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
try {
|
|
185
|
+
fs.unlinkSync(tmpFile);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* nothing to clean */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// An ImportError is the caller's problem (a bad link, this deck, a deck that
|
|
193
|
+
// was never saved), so it comes back as a 400 with its code -- the picker shows
|
|
194
|
+
// a different thing for each. Anything else is ours: a 500 with the message.
|
|
195
|
+
function sendResult(res, work) {
|
|
196
|
+
void work()
|
|
197
|
+
.then((body) => sendJson(res, 200, body))
|
|
198
|
+
.catch((err) => {
|
|
199
|
+
if (err instanceof ImportError)
|
|
200
|
+
return sendJson(res, 400, { error: err.message, code: err.code });
|
|
201
|
+
sendJson(res, 500, { error: errorMessage(err) });
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
export function handleImportApi(deckDir, req, res, reqPath) {
|
|
205
|
+
const action = reqPath.slice(IMPORT_API_PREFIX.length);
|
|
206
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
207
|
+
if (action === "list") {
|
|
208
|
+
sendResult(res, () => listImportable(deckDir, asTab(url.searchParams.get("tab"))));
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (action === "resolve") {
|
|
212
|
+
const ref = url.searchParams.get("ref") ?? "";
|
|
213
|
+
sendResult(res, async () => ({ deck: await resolveDeckRef(deckDir, ref) }));
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
if (action === "files") {
|
|
217
|
+
const deckId = url.searchParams.get("deckId") ?? "";
|
|
218
|
+
sendResult(res, async () => ({ fileCount: await sourceFileCount(deckId) }));
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (action === "add") {
|
|
222
|
+
sendResult(res, async () => {
|
|
223
|
+
let body;
|
|
224
|
+
try {
|
|
225
|
+
body = JSON.parse(await readRequestBody(req));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
throw new ImportError("no-deck-ref", "Invalid JSON body.");
|
|
229
|
+
}
|
|
230
|
+
const deckRef = typeof body.deckRef === "string" ? body.deckRef : "";
|
|
231
|
+
const result = await addImportTo(deckDir, { deckRef });
|
|
232
|
+
// The lists said which decks were importable; one of them just stopped
|
|
233
|
+
// being a candidate (or changed version), so the cached answers are stale.
|
|
234
|
+
listCache.clear();
|
|
235
|
+
return result;
|
|
236
|
+
});
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
return (sendJson(res, 404, { error: `Unknown import action: ${action}` }), true);
|
|
240
|
+
}
|
package/dist/imports.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export declare const IMPORTS_DIR = "imports";
|
|
2
|
+
export type ImportErrorCode = 'no-deck-ref' | 'bad-deck-ref' | 'no-such-dir' | 'self-import' | 'no-source' | 'bad-alias';
|
|
3
|
+
export declare class ImportError extends Error {
|
|
4
|
+
code: ImportErrorCode;
|
|
5
|
+
constructor(code: ImportErrorCode, message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function parseDeckRef(raw: string): string | null;
|
|
2
8
|
export declare function lockImportTree(dir: string): void;
|
|
3
9
|
export declare function syncImportDependencies(deckDir: string): string[];
|
|
4
10
|
export declare function restoreMissingImports(deckDir: string): Promise<string[]>;
|
|
@@ -11,8 +17,22 @@ export interface ImportStatus {
|
|
|
11
17
|
via?: string;
|
|
12
18
|
}
|
|
13
19
|
export declare function importStatuses(deckDir: string): Promise<ImportStatus[]>;
|
|
20
|
+
export declare function importedDeckIds(deckDir: string): Set<string>;
|
|
21
|
+
export interface AddImportResult {
|
|
22
|
+
deckId: string;
|
|
23
|
+
alias: string;
|
|
24
|
+
replaced: boolean;
|
|
25
|
+
/** Aliases pulled in because the imported deck depends on them. */
|
|
26
|
+
transitive: string[];
|
|
27
|
+
/** `name@range (from alias)` entries merged into the deck's package.json. */
|
|
28
|
+
dependencies: string[];
|
|
29
|
+
}
|
|
30
|
+
export declare function addImportTo(dir: string, options?: {
|
|
31
|
+
deckRef?: string;
|
|
32
|
+
alias?: string;
|
|
33
|
+
}): Promise<AddImportResult>;
|
|
14
34
|
export declare function addImport(dir: string, options?: {
|
|
15
|
-
|
|
35
|
+
deckRef?: string;
|
|
16
36
|
alias?: string;
|
|
17
37
|
}): Promise<void>;
|
|
18
38
|
export declare function updateImport(dir: string, options?: {
|
package/dist/imports.js
CHANGED
|
@@ -25,6 +25,37 @@ import { readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
|
|
|
25
25
|
// node_modules gets. That also means `get-deck` leaves an existing `imports/`
|
|
26
26
|
// alone when it refreshes a deck.
|
|
27
27
|
export const IMPORTS_DIR = 'imports';
|
|
28
|
+
export class ImportError extends Error {
|
|
29
|
+
code;
|
|
30
|
+
constructor(code, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'ImportError';
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// What someone has in hand is a deck id or the URL of the page they were just
|
|
37
|
+
// looking at, so both are accepted here rather than in a separate resolve step.
|
|
38
|
+
// Recognized: a bare id, a deck page (`castle.xyz/d/<id>`), and the cloud editor
|
|
39
|
+
// (`castle.xyz/admin/cloud/<id>`) -- for anything else the last path segment is
|
|
40
|
+
// tried, since every castle deck URL ends in the id.
|
|
41
|
+
export function parseDeckRef(raw) {
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (!trimmed)
|
|
44
|
+
return null;
|
|
45
|
+
if (!trimmed.includes('/'))
|
|
46
|
+
return api.isDeckIdShaped(trimmed) ? trimmed : null;
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
55
|
+
const afterD = segments.indexOf('d');
|
|
56
|
+
const candidate = afterD >= 0 ? segments[afterD + 1] : segments[segments.length - 1];
|
|
57
|
+
return candidate && api.isDeckIdShaped(candidate) ? candidate : null;
|
|
58
|
+
}
|
|
28
59
|
// Only the `imports` key is ours; everything else in castle.json (deck identity,
|
|
29
60
|
// editor config) is preserved as-is.
|
|
30
61
|
function writeImportPin(dir, alias, pin) {
|
|
@@ -70,6 +101,25 @@ function qualifiedAlias(meta, deckId) {
|
|
|
70
101
|
return `${user}.${title}`;
|
|
71
102
|
return title || slugifyAlias(deckId) || deckId;
|
|
72
103
|
}
|
|
104
|
+
// A derived alias can already be taken -- two people with a same-named deck, or
|
|
105
|
+
// the same title twice. Reusing it would silently replace a different
|
|
106
|
+
// dependency, so a taken name gets a number. A name already pointing at THIS
|
|
107
|
+
// deck is not a collision: that one is the update case, and keeping it is what
|
|
108
|
+
// makes re-importing a deck update it in place.
|
|
109
|
+
const MAX_ALIAS_ATTEMPTS = 100;
|
|
110
|
+
function availableAlias(deckDir, alias, deckId) {
|
|
111
|
+
const pins = readCastleJson(deckDir)?.imports ?? {};
|
|
112
|
+
const taken = (name) => pins[name] !== undefined
|
|
113
|
+
? pins[name].deckId !== deckId
|
|
114
|
+
: fs.existsSync(path.join(deckDir, IMPORTS_DIR, name));
|
|
115
|
+
if (!taken(alias))
|
|
116
|
+
return alias;
|
|
117
|
+
for (let n = 2; n <= MAX_ALIAS_ATTEMPTS; n++) {
|
|
118
|
+
if (!taken(`${alias}-${n}`))
|
|
119
|
+
return `${alias}-${n}`;
|
|
120
|
+
}
|
|
121
|
+
throw new ImportError('bad-alias', `Too many imports already named "${alias}"; pass --as to name this one.`);
|
|
122
|
+
}
|
|
73
123
|
// Dependencies are made read-only ON DISK, not just refused by the files API.
|
|
74
124
|
// The API guard only covers the editor; an agent or a shell command writes
|
|
75
125
|
// straight to the filesystem, and the common "write a temp file and rename it"
|
|
@@ -386,49 +436,71 @@ async function addTransitiveImports(deckDir, viaAlias, seen) {
|
|
|
386
436
|
}
|
|
387
437
|
// Every deckId this deck already has, so a transitive walk doesn't refetch or
|
|
388
438
|
// loop on a cycle.
|
|
389
|
-
function importedDeckIds(deckDir) {
|
|
439
|
+
export function importedDeckIds(deckDir) {
|
|
390
440
|
const pins = readCastleJson(deckDir)?.imports ?? {};
|
|
391
441
|
return new Set(Object.values(pins).map((p) => p?.deckId).filter((id) => !!id));
|
|
392
442
|
}
|
|
393
|
-
|
|
443
|
+
// The import itself. Throws ImportError rather than exiting, because this also
|
|
444
|
+
// runs inside the serve (from the editor's Import panel), where taking the
|
|
445
|
+
// process down is not an option. The CLI wrapper below prints and exits.
|
|
446
|
+
export async function addImportTo(dir, options = {}) {
|
|
394
447
|
const targetDir = path.resolve(dir);
|
|
395
|
-
const
|
|
448
|
+
const ref = options.deckRef?.trim();
|
|
449
|
+
if (!ref) {
|
|
450
|
+
throw new ImportError('no-deck-ref', 'No deck given. Pass a deck id or a castle.xyz deck link.');
|
|
451
|
+
}
|
|
452
|
+
const deckId = parseDeckRef(ref);
|
|
396
453
|
if (!deckId) {
|
|
397
|
-
|
|
398
|
-
process.exit(1);
|
|
454
|
+
throw new ImportError('bad-deck-ref', `Not a deck id or a castle deck link: ${ref}`);
|
|
399
455
|
}
|
|
400
456
|
if (!fs.existsSync(targetDir)) {
|
|
401
|
-
|
|
402
|
-
process.exit(1);
|
|
457
|
+
throw new ImportError('no-such-dir', `No such directory: ${targetDir}`);
|
|
403
458
|
}
|
|
404
459
|
if (readCastleJson(targetDir)?.deckId === deckId) {
|
|
405
|
-
|
|
406
|
-
process.exit(1);
|
|
460
|
+
throw new ImportError('self-import', `Deck ${deckId} is this deck -- a deck can't import itself.`);
|
|
407
461
|
}
|
|
408
462
|
const [source, meta] = await Promise.all([
|
|
409
463
|
api.webDeckSource(deckId),
|
|
410
464
|
api.deckMeta(deckId).catch(() => null),
|
|
411
465
|
]);
|
|
412
466
|
if (!source) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
process.exit(1);
|
|
467
|
+
throw new ImportError('no-source', `No source archive on the server for deck ${deckId}. ` +
|
|
468
|
+
'Its owner needs to run `castle-web save-deck` before it can be imported.');
|
|
416
469
|
}
|
|
417
|
-
const
|
|
418
|
-
if (!
|
|
419
|
-
|
|
420
|
-
process.exit(1);
|
|
470
|
+
const given = options.alias ? slugifyGivenAlias(options.alias) : '';
|
|
471
|
+
if (options.alias && !given) {
|
|
472
|
+
throw new ImportError('bad-alias', 'Could not derive a usable name; pick one with letters or digits.');
|
|
421
473
|
}
|
|
474
|
+
// An explicit `--as` is the caller's call, collision and all. A derived one
|
|
475
|
+
// gets a number instead, since nothing asked for that particular name.
|
|
476
|
+
const alias = given || availableAlias(targetDir, qualifiedAlias(meta, deckId), deckId);
|
|
422
477
|
const { replaced } = await placeImport(targetDir, alias, deckId, source);
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
478
|
+
const transitive = await addTransitiveImports(targetDir, alias, importedDeckIds(targetDir));
|
|
479
|
+
const dependencies = syncImportDependencies(targetDir);
|
|
480
|
+
return { deckId, alias, replaced, transitive, dependencies };
|
|
481
|
+
}
|
|
482
|
+
export async function addImport(dir, options = {}) {
|
|
483
|
+
let result;
|
|
484
|
+
try {
|
|
485
|
+
result = await addImportTo(dir, options);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
if (!(err instanceof ImportError))
|
|
489
|
+
throw err;
|
|
490
|
+
console.error(err.message);
|
|
491
|
+
if (err.code === 'no-deck-ref') {
|
|
492
|
+
console.error('Usage: castle-web add-import <deckId|url> [dir] [--as ALIAS]');
|
|
493
|
+
}
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
console.log(`${result.replaced ? 'Updated' : 'Imported'} ${result.deckId} at ` +
|
|
497
|
+
`${IMPORTS_DIR}/${result.alias} (read-only on disk).`);
|
|
498
|
+
for (const t of result.transitive) {
|
|
499
|
+
console.log(`Also imported ${IMPORTS_DIR}/${t} (needed by ${result.alias})`);
|
|
500
|
+
}
|
|
501
|
+
for (const dep of result.dependencies)
|
|
430
502
|
console.log(`Added dependency ${dep}`);
|
|
431
|
-
if (
|
|
503
|
+
if (result.dependencies.length > 0)
|
|
432
504
|
console.log('Run `castle-web install` to install them.');
|
|
433
505
|
}
|
|
434
506
|
// `castle-web update-import [alias] [dir]`: re-fetch an import (or all of them)
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { serve } from './serve.js';
|
|
|
6
6
|
import { saveDeck } from './save-deck.js';
|
|
7
7
|
import { getDeck } from './get-deck.js';
|
|
8
8
|
import { addImport, updateImport } from './imports.js';
|
|
9
|
+
import { listDecks } from './list-decks.js';
|
|
9
10
|
import { getCliVersion, init } from './init.js';
|
|
10
11
|
import { install } from './install.js';
|
|
11
12
|
import { connectWS, savePreviewImage, savePreviewIfNeeded, takeScreenshot } from './preview.js';
|
|
@@ -21,6 +22,7 @@ const FLAGS_WITH_VALUES = new Set([
|
|
|
21
22
|
'--caption',
|
|
22
23
|
'--visibility',
|
|
23
24
|
'--as',
|
|
25
|
+
'--kind',
|
|
24
26
|
]);
|
|
25
27
|
function findPositionalDir() {
|
|
26
28
|
for (let i = 1; i < args.length; i++) {
|
|
@@ -82,7 +84,8 @@ function usage() {
|
|
|
82
84
|
castle-web save-preview-image [dir] [--port PORT] [--no-restart]
|
|
83
85
|
castle-web save-deck [dir] [--title TITLE] [--caption TEXT] [--visibility unlisted|private]
|
|
84
86
|
castle-web get-deck [dir] [--deck-id ID] [--force] (replaces the source if the deck is already there)
|
|
85
|
-
castle-web
|
|
87
|
+
castle-web list-decks [dir] [--kind mine|saved|kits] (decks this one could import; ids are what add-import takes)
|
|
88
|
+
castle-web add-import <deckId|url> [dir] [--as ALIAS] (adds another deck as a read-only dependency in imports/)
|
|
86
89
|
castle-web update-import [alias] [dir] [--check] [--revert] (re-fetches imports; no alias means all)
|
|
87
90
|
castle-web install [dir]
|
|
88
91
|
castle-web login
|
|
@@ -146,12 +149,16 @@ async function main() {
|
|
|
146
149
|
});
|
|
147
150
|
break;
|
|
148
151
|
}
|
|
152
|
+
case 'list-decks': {
|
|
153
|
+
await listDecks(findPositionalDir(), { kind: getFlagValue('--kind') });
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
149
156
|
case 'add-import': {
|
|
150
|
-
// The deck id
|
|
151
|
-
// optional second, defaulting to the cwd.
|
|
157
|
+
// The deck -- an id or the link to its page -- is the first positional;
|
|
158
|
+
// the importing deck's dir is an optional second, defaulting to the cwd.
|
|
152
159
|
const positionals = readPositionals();
|
|
153
160
|
await addImport(positionals[1] ?? '.', {
|
|
154
|
-
|
|
161
|
+
deckRef: getFlagValue('--deck-id') ?? positionals[0],
|
|
155
162
|
alias: getFlagValue('--as'),
|
|
156
163
|
});
|
|
157
164
|
break;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// `castle-web list-decks` -- what this deck could import, for an agent rather
|
|
2
|
+
// than for the editor's picker. Same answer the picker gets (importBrowse's
|
|
3
|
+
// `/__castle/import/*` backend), printed one deck per line.
|
|
4
|
+
//
|
|
5
|
+
// The agent needs this because `add-import` takes a deck id, and until now
|
|
6
|
+
// nothing told it which ids exist -- so it could only import a deck the user
|
|
7
|
+
// had already named.
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { listImportable } from './importBrowse.js';
|
|
10
|
+
const KINDS = ['mine', 'saved', 'kits'];
|
|
11
|
+
function isKind(value) {
|
|
12
|
+
return KINDS.includes(value);
|
|
13
|
+
}
|
|
14
|
+
// One deck per line, id first: the id is what `add-import` takes, so it reads
|
|
15
|
+
// straight out of the listing without the agent having to parse a sentence.
|
|
16
|
+
function formatDeck(deck) {
|
|
17
|
+
const parts = [deck.deckId, deck.title];
|
|
18
|
+
if (deck.creator)
|
|
19
|
+
parts.push(`@${deck.creator}`);
|
|
20
|
+
if (deck.imported)
|
|
21
|
+
parts.push('(already imported)');
|
|
22
|
+
return parts.join(' ');
|
|
23
|
+
}
|
|
24
|
+
export async function listDecks(dir, options = {}) {
|
|
25
|
+
const kind = options.kind ?? 'mine';
|
|
26
|
+
if (!isKind(kind)) {
|
|
27
|
+
console.error(`Unknown kind "${kind}". Use one of: ${KINDS.join(', ')}.`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const list = await listImportable(path.resolve(dir), kind);
|
|
31
|
+
if (!list.signedIn) {
|
|
32
|
+
console.error('Not signed in. Run `castle-web login` first.');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
if (list.decks.length === 0) {
|
|
36
|
+
console.log(`No ${kind} decks with source files to import.`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const deck of list.decks)
|
|
40
|
+
console.log(formatDeck(deck));
|
|
41
|
+
if (list.truncated) {
|
|
42
|
+
console.log(`(first ${list.decks.length}; there are more)`);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/metering.d.ts
CHANGED
package/dist/metering.js
CHANGED
|
@@ -145,6 +145,9 @@ export async function fetchBudget() {
|
|
|
145
145
|
limitMicros: typeof body.limitMicros === "number" ? body.limitMicros : null,
|
|
146
146
|
resetAtMs: typeof body.resetAtMs === "number" ? body.resetAtMs : 0,
|
|
147
147
|
blocked: body.blocked,
|
|
148
|
+
blockedModelPrefixes: Array.isArray(body.blockedModelPrefixes)
|
|
149
|
+
? body.blockedModelPrefixes.filter((p) => typeof p === "string")
|
|
150
|
+
: [],
|
|
148
151
|
};
|
|
149
152
|
}
|
|
150
153
|
catch {
|
package/dist/save-deck.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const SOURCE_ARCHIVE_EXCLUDES: string[];
|
|
2
|
-
export declare function runTar(args: string[]
|
|
2
|
+
export declare function runTar(args: string[], opts?: {
|
|
3
|
+
capture?: boolean;
|
|
4
|
+
}): Promise<string>;
|
|
3
5
|
export declare function archiveSource(projectDir: string): Promise<Buffer>;
|
|
4
6
|
export type SaveVisibility = 'unlisted' | 'private';
|
|
5
7
|
export interface SaveDeckOptions {
|
package/dist/save-deck.js
CHANGED
|
@@ -12,10 +12,14 @@ import { bundleProject } from './bundle.js';
|
|
|
12
12
|
// part of it. (Being on this list also means `get-deck` leaves an existing
|
|
13
13
|
// `imports/` in place when it refreshes a deck.)
|
|
14
14
|
export const SOURCE_ARCHIVE_EXCLUDES = ['node_modules', 'dist', '.castle', '.git', 'imports'];
|
|
15
|
-
|
|
15
|
+
// `capture` keeps tar's stdout and resolves with it -- for listing an archive
|
|
16
|
+
// (`-tzf`), where the output IS the answer. Packing and unpacking ignore it.
|
|
17
|
+
export function runTar(args, opts = {}) {
|
|
16
18
|
return new Promise((resolve, reject) => {
|
|
17
|
-
const child = spawn('tar', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
19
|
+
const child = spawn('tar', args, { stdio: ['ignore', opts.capture ? 'pipe' : 'ignore', 'pipe'] });
|
|
20
|
+
let stdout = '';
|
|
18
21
|
let stderr = '';
|
|
22
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
19
23
|
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
20
24
|
child.on('error', reject);
|
|
21
25
|
child.on('close', (code) => {
|
|
@@ -23,7 +27,7 @@ export function runTar(args) {
|
|
|
23
27
|
reject(new Error(`tar exited with code ${code}: ${stderr}`));
|
|
24
28
|
return;
|
|
25
29
|
}
|
|
26
|
-
resolve();
|
|
30
|
+
resolve(stdout);
|
|
27
31
|
});
|
|
28
32
|
});
|
|
29
33
|
}
|
package/dist/serve.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as path from 'path';
|
|
|
4
4
|
import { spawn } from 'child_process';
|
|
5
5
|
import { createServer } from 'vite';
|
|
6
6
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
7
|
-
import { createIdeServer } from './ide.js';
|
|
7
|
+
import { createIdeServer, FAVICON_LINK_TAGS } from './ide.js';
|
|
8
8
|
import { createAgentServer } from './agent.js';
|
|
9
9
|
import { sceneFilesPlugin, importsAliasPlugin } from './vitePlugins.js';
|
|
10
10
|
import { installFilesChangedWatcher } from './filesChanged.js';
|
|
@@ -58,7 +58,15 @@ function castlePlugin(wsPort, ideServer, agentServer) {
|
|
|
58
58
|
transformIndexHtml: {
|
|
59
59
|
order: 'pre',
|
|
60
60
|
handler(html) {
|
|
61
|
-
|
|
61
|
+
// Give the deck page Castle's favicon too -- it's a real page when
|
|
62
|
+
// opened directly (play surface / `?edit=0`), not just the editor's
|
|
63
|
+
// iframe. Injected here rather than in the kit templates so bare
|
|
64
|
+
// `--kit none` decks and already-scaffolded decks get it as well; a
|
|
65
|
+
// deck that declares its own icon keeps it.
|
|
66
|
+
const favicon = /<link[^>]+rel=["']?[^"'>]*icon/i.test(html)
|
|
67
|
+
? ''
|
|
68
|
+
: `\n ${FAVICON_LINK_TAGS}`;
|
|
69
|
+
return html.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${favicon}\n <script>window.CastleEmbed={edit:true,host:'dev'};</script>\n ${CONSOLE_CAPTURE}`);
|
|
62
70
|
},
|
|
63
71
|
},
|
|
64
72
|
configureServer(server) {
|