castle-web-cli 0.4.92 → 0.4.94
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.js +141 -22
- package/dist/api.d.ts +8 -0
- package/dist/api.js +10 -0
- package/dist/bundle.js +2 -2
- package/dist/get-deck.d.ts +1 -1
- package/dist/get-deck.js +93 -22
- package/dist/ide.js +123 -15
- package/dist/imports.d.ts +22 -0
- package/dist/imports.js +549 -0
- package/dist/index.js +45 -15
- package/dist/init.js +169 -1
- package/dist/install.d.ts +1 -1
- package/dist/install.js +14 -1
- package/dist/metering.d.ts +1 -0
- package/dist/metering.js +1 -1
- package/dist/native/loop.js +1 -0
- package/dist/native/openrouter.d.ts +1 -0
- package/dist/native/openrouter.js +13 -6
- package/dist/native/types.d.ts +1 -0
- package/dist/normalize.js +4 -0
- package/dist/openrouter-catalog.d.ts +3 -1
- package/dist/openrouter-catalog.js +15 -10
- package/dist/save-deck.d.ts +2 -0
- package/dist/save-deck.js +25 -19
- package/dist/serve.js +2 -2
- package/dist/shell/assets/{index-DSIr52Kl.css → index-0Nmmod9f.css} +1 -1
- package/dist/shell/assets/{index-BFCG4tLs.js → index-DG5ZD4p8.js} +21 -21
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.d.ts +1 -0
- package/dist/vitePlugins.js +33 -0
- package/kits/basic-2d/CLAUDE.md +20 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +6 -1
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +14 -8
- package/kits/basic-2d/editors/behaviorRegistry.js +8 -2
- package/kits/basic-2d/engine/behaviorExtensions.js +5 -1
- package/kits/basic-2d/engine/blueprint.js +39 -3
- package/kits/basic-2d/engine/files.js +26 -5
- package/kits/basic-2d/engine/scene.js +4 -1
- package/kits/basic-2d/engine/systemRegistry.js +5 -1
- package/package.json +1 -1
- package/dist/pull.d.ts +0 -4
- package/dist/pull.js +0 -119
package/dist/imports.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { nanoid } from 'nanoid';
|
|
5
|
+
import * as api from './api.js';
|
|
6
|
+
import { runTar } from './save-deck.js';
|
|
7
|
+
import { normalizeDeckPackageJson } from './normalize.js';
|
|
8
|
+
import { getKitsDir } from './localPaths.js';
|
|
9
|
+
// Adding another deck as a dependency (`castle-web add-import`; removing one is a
|
|
10
|
+
// later command). Deliberately NOT `get-deck`: that one
|
|
11
|
+
// replaces THIS deck's own source from the server (and carries guards for the
|
|
12
|
+
// destruction that implies). Here the fetched deck is somebody else's, it lands in
|
|
13
|
+
// a subdirectory, and nothing the deck already has is touched.
|
|
14
|
+
//
|
|
15
|
+
// Dependencies live in `imports/<alias>/` inside the deck. Real files, so the
|
|
16
|
+
// engine's globs, the agent's greps, and vite all see them with no indirection.
|
|
17
|
+
// They are READ-ONLY by policy (the files API refuses writes under `imports/`) --
|
|
18
|
+
// a dependency is a reference to someone else's deck, so editing it in place would
|
|
19
|
+
// mean editing their deck. Forking (keeping your own version of a dep's file) is a
|
|
20
|
+
// later feature; copying a file out into the deck works meanwhile.
|
|
21
|
+
//
|
|
22
|
+
// `imports/` is excluded from the deck's saved source archive (see
|
|
23
|
+
// SOURCE_ARCHIVE_EXCLUDES): the pins in castle.json are what identify a
|
|
24
|
+
// dependency, and the files are re-fetchable from them -- same treatment
|
|
25
|
+
// node_modules gets. That also means `get-deck` leaves an existing `imports/`
|
|
26
|
+
// alone when it refreshes a deck.
|
|
27
|
+
export const IMPORTS_DIR = 'imports';
|
|
28
|
+
function readCastleJson(dir) {
|
|
29
|
+
const p = path.join(dir, 'castle.json');
|
|
30
|
+
if (!fs.existsSync(p))
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
throw new Error(`Could not read ${p}: ${e instanceof Error ? e.message : String(e)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Only the `imports` key is ours; everything else in castle.json (deck identity,
|
|
40
|
+
// editor config) is preserved as-is.
|
|
41
|
+
function writeImportPin(dir, alias, pin) {
|
|
42
|
+
const existing = readCastleJson(dir) ?? {};
|
|
43
|
+
const next = {
|
|
44
|
+
...existing,
|
|
45
|
+
imports: { ...(existing.imports ?? {}), [alias]: pin },
|
|
46
|
+
};
|
|
47
|
+
fs.writeFileSync(path.join(dir, 'castle.json'), JSON.stringify(next, null, 2) + '\n', 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
// Aliases name the directory AND every path that references a dependency's files
|
|
50
|
+
// (`imports/<alias>/drawings/rod.pxart`), so they have to be filesystem- and
|
|
51
|
+
// URL-clean, and stable once chosen -- an alias is never re-derived from
|
|
52
|
+
// upstream names, or renaming a deck upstream would break every ref to it here.
|
|
53
|
+
function slugifyAlias(name) {
|
|
54
|
+
return name
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
57
|
+
.replace(/^-+|-+$/g, '')
|
|
58
|
+
.slice(0, 40);
|
|
59
|
+
}
|
|
60
|
+
// An explicit `--as` may already be qualified (`someone.their-deck`); slugify
|
|
61
|
+
// each part so the separator survives.
|
|
62
|
+
function slugifyGivenAlias(name) {
|
|
63
|
+
return name
|
|
64
|
+
.split('.')
|
|
65
|
+
.map(slugifyAlias)
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join('.');
|
|
68
|
+
}
|
|
69
|
+
// Aliases are qualified by author -- `lovefromtom.fish-town` -- because deck
|
|
70
|
+
// titles are not unique across users (nor even within one), and an import's name
|
|
71
|
+
// is read far more often than it is typed. `.` rather than `:` or a second path
|
|
72
|
+
// segment: a colon is illegal in Windows filenames and reads as `host:path` to
|
|
73
|
+
// scp/rsync, and a nested directory would add a level to every files-panel tree
|
|
74
|
+
// and every glob for a scope that is only occasionally interesting.
|
|
75
|
+
//
|
|
76
|
+
// Falls back to whatever is available: title alone, then the deck id.
|
|
77
|
+
function qualifiedAlias(meta, deckId) {
|
|
78
|
+
const user = slugifyAlias(meta?.creator?.username ?? '');
|
|
79
|
+
const title = slugifyAlias(meta?.title ?? '');
|
|
80
|
+
if (user && title)
|
|
81
|
+
return `${user}.${title}`;
|
|
82
|
+
return title || slugifyAlias(deckId) || deckId;
|
|
83
|
+
}
|
|
84
|
+
// Dependencies are made read-only ON DISK, not just refused by the files API.
|
|
85
|
+
// The API guard only covers the editor; an agent or a shell command writes
|
|
86
|
+
// straight to the filesystem, and the common "write a temp file and rename it"
|
|
87
|
+
// pattern (sed -i, most editors, most tooling) sails past a read-only FILE by
|
|
88
|
+
// replacing it -- only a read-only DIRECTORY stops that. So both are locked.
|
|
89
|
+
//
|
|
90
|
+
// The cost is that `imports/<alias>` can't be deleted until it's unlocked, which
|
|
91
|
+
// is why every path in this file that replaces a dependency unlocks it first.
|
|
92
|
+
function chmodTree(dir, fileMode, dirMode) {
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const child = path.join(dir, entry.name);
|
|
102
|
+
if (entry.isDirectory())
|
|
103
|
+
chmodTree(child, fileMode, dirMode);
|
|
104
|
+
else {
|
|
105
|
+
try {
|
|
106
|
+
fs.chmodSync(child, fileMode);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* a file we can't chmod is not worth failing the import over */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// The directory goes last: its contents have to be reachable while we walk it.
|
|
114
|
+
try {
|
|
115
|
+
fs.chmodSync(dir, dirMode);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* as above */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function lockImportTree(dir) {
|
|
122
|
+
chmodTree(dir, 0o444, 0o555);
|
|
123
|
+
}
|
|
124
|
+
// Restore write permission so the tree can be replaced or removed. Directories
|
|
125
|
+
// first, or their contents are unreachable.
|
|
126
|
+
function unlockImportTree(dir) {
|
|
127
|
+
try {
|
|
128
|
+
fs.chmodSync(dir, 0o755);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let entries;
|
|
134
|
+
try {
|
|
135
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const child = path.join(dir, entry.name);
|
|
142
|
+
if (entry.isDirectory())
|
|
143
|
+
unlockImportTree(child);
|
|
144
|
+
else {
|
|
145
|
+
try {
|
|
146
|
+
fs.chmodSync(child, 0o644);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* best effort */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// An import's code runs from the IMPORTING deck's node_modules -- there is one
|
|
155
|
+
// install at the deck root, and a dependency's own node_modules is neither
|
|
156
|
+
// fetched (it is excluded from the source archive) nor consulted. So whatever a
|
|
157
|
+
// dependency declares has to be declared by the deck too.
|
|
158
|
+
//
|
|
159
|
+
// The deck's own declarations always win: this only ADDS packages the deck
|
|
160
|
+
// doesn't already name, so a deliberate version choice is never overwritten by
|
|
161
|
+
// an import. Nothing records which entries came from an import, so removing one
|
|
162
|
+
// leaves its packages behind -- harmless (an unused package), and the honest
|
|
163
|
+
// alternative needs provenance we don't track yet: once a deck starts importing
|
|
164
|
+
// a package directly, "who added it" is no longer answerable from package.json.
|
|
165
|
+
export function syncImportDependencies(deckDir) {
|
|
166
|
+
const deckPkgPath = path.join(deckDir, 'package.json');
|
|
167
|
+
let deckPkg;
|
|
168
|
+
try {
|
|
169
|
+
deckPkg = JSON.parse(fs.readFileSync(deckPkgPath, 'utf-8'));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return []; // no (or unreadable) package.json -- nothing to merge into
|
|
173
|
+
}
|
|
174
|
+
let aliases;
|
|
175
|
+
try {
|
|
176
|
+
aliases = fs.readdirSync(path.join(deckDir, IMPORTS_DIR)).sort();
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return []; // no imports
|
|
180
|
+
}
|
|
181
|
+
const deps = { ...(deckPkg.dependencies ?? {}) };
|
|
182
|
+
const added = [];
|
|
183
|
+
for (const alias of aliases) {
|
|
184
|
+
let importPkg;
|
|
185
|
+
try {
|
|
186
|
+
importPkg = JSON.parse(fs.readFileSync(path.join(deckDir, IMPORTS_DIR, alias, 'package.json'), 'utf-8'));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
continue; // an import without a package.json declares nothing
|
|
190
|
+
}
|
|
191
|
+
for (const [name, range] of Object.entries(importPkg.dependencies ?? {})) {
|
|
192
|
+
if (deps[name] !== undefined)
|
|
193
|
+
continue;
|
|
194
|
+
deps[name] = range;
|
|
195
|
+
added.push(`${name}@${range} (from ${alias})`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (added.length === 0)
|
|
199
|
+
return [];
|
|
200
|
+
deckPkg.dependencies = Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
|
|
201
|
+
fs.writeFileSync(deckPkgPath, JSON.stringify(deckPkg, null, 2) + '\n', 'utf-8');
|
|
202
|
+
// A dependency published from a checkout can carry `file:` refs that mean
|
|
203
|
+
// nothing here; normalize rewrites those to published ones.
|
|
204
|
+
normalizeDeckPackageJson(deckDir);
|
|
205
|
+
return added;
|
|
206
|
+
}
|
|
207
|
+
// `imports/` is derived state (it is excluded from the deck's archive), so a deck
|
|
208
|
+
// that has been saved and fetched back -- the cloud's open-a-deck path, another
|
|
209
|
+
// machine, a restored sandbox -- arrives with its pins but none of the files, and
|
|
210
|
+
// won't run until they are back. Rebuilding them from the pins is `install`'s
|
|
211
|
+
// job, alongside node_modules.
|
|
212
|
+
export async function restoreMissingImports(deckDir) {
|
|
213
|
+
const pins = (readCastleJson(deckDir)?.imports ?? {});
|
|
214
|
+
const restored = [];
|
|
215
|
+
for (const [alias, pin] of Object.entries(pins)) {
|
|
216
|
+
const destDir = path.join(deckDir, IMPORTS_DIR, alias);
|
|
217
|
+
if (fs.existsSync(destDir))
|
|
218
|
+
continue;
|
|
219
|
+
if (pin?.deckId) {
|
|
220
|
+
const source = await api.webDeckSource(pin.deckId).catch(() => null);
|
|
221
|
+
if (!source) {
|
|
222
|
+
console.warn(`Could not fetch import "${alias}" (${pin.deckId}); it has no source on the server.`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
await fetchDeckSourceInto(source.archiveUrl, destDir);
|
|
226
|
+
}
|
|
227
|
+
else if (pin?.source === 'builtin') {
|
|
228
|
+
// Scaffolded from the kit shipped with the CLI: the same copy is still
|
|
229
|
+
// here, so there is nothing to fetch.
|
|
230
|
+
const kitDir = path.join(getKitsDir(), pin.kit ?? alias);
|
|
231
|
+
if (!fs.existsSync(kitDir)) {
|
|
232
|
+
console.warn(`Could not restore import "${alias}": this CLI ships no kit by that name.`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
|
236
|
+
fs.cpSync(kitDir, destDir, {
|
|
237
|
+
recursive: true,
|
|
238
|
+
verbatimSymlinks: true,
|
|
239
|
+
filter: (src) => src === kitDir || !['node_modules', '.castle', 'dist', '.git'].includes(path.basename(src)),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
console.warn(`Import "${alias}" has no deckId to fetch from; skipping.`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
lockImportTree(destDir);
|
|
247
|
+
restored.push(alias);
|
|
248
|
+
}
|
|
249
|
+
return restored;
|
|
250
|
+
}
|
|
251
|
+
// Download an archive and unpack it at `destDir`, via a temp dir so a failure
|
|
252
|
+
// partway leaves nothing behind.
|
|
253
|
+
async function fetchDeckSourceInto(archiveUrl, destDir) {
|
|
254
|
+
const res = await fetch(archiveUrl, { signal: AbortSignal.timeout(60000) });
|
|
255
|
+
if (!res.ok)
|
|
256
|
+
throw new Error(`Archive fetch failed: HTTP ${res.status}`);
|
|
257
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
258
|
+
const tmpFile = path.join(os.tmpdir(), `castle-import-${nanoid(8)}.tar.gz`);
|
|
259
|
+
const tmpDir = path.join(os.tmpdir(), `castle-import-${nanoid(8)}`);
|
|
260
|
+
fs.writeFileSync(tmpFile, buf);
|
|
261
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
262
|
+
try {
|
|
263
|
+
await runTar(['-xzf', tmpFile, '-C', tmpDir]);
|
|
264
|
+
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
|
265
|
+
fs.renameSync(tmpDir, destDir);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
try {
|
|
269
|
+
fs.unlinkSync(tmpFile);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* nothing to clean */
|
|
273
|
+
}
|
|
274
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Checking every import means a request per import, so the answer is cached
|
|
278
|
+
// briefly: the files panel asks on open and after any file change, and none of
|
|
279
|
+
// those are reasons to re-ask the server.
|
|
280
|
+
const STATUS_TTL_MS = 60_000;
|
|
281
|
+
const statusCache = new Map();
|
|
282
|
+
// What each import is pinned at, and whether the server has moved past it. A
|
|
283
|
+
// deck's imports don't change under it -- an update is something the person
|
|
284
|
+
// editing chooses -- so this only ever reports; taking the update is
|
|
285
|
+
// `castle-web update-import`.
|
|
286
|
+
export async function importStatuses(deckDir) {
|
|
287
|
+
const cached = statusCache.get(deckDir);
|
|
288
|
+
if (cached && Date.now() - cached.at < STATUS_TTL_MS)
|
|
289
|
+
return cached.value;
|
|
290
|
+
const pins = (readCastleJson(deckDir)?.imports ?? {});
|
|
291
|
+
const value = await Promise.all(Object.entries(pins).map(async ([alias, pin]) => {
|
|
292
|
+
const base = {
|
|
293
|
+
alias,
|
|
294
|
+
deckId: pin?.deckId ?? null,
|
|
295
|
+
version: pin?.version ?? '',
|
|
296
|
+
latest: null,
|
|
297
|
+
updateAvailable: false,
|
|
298
|
+
...(pin?.via ? { via: pin.via } : {}),
|
|
299
|
+
};
|
|
300
|
+
// A kit copied from the CLI updates with the CLI; there is no deck to ask.
|
|
301
|
+
if (!pin?.deckId)
|
|
302
|
+
return base;
|
|
303
|
+
const source = await api.webDeckSource(pin.deckId).catch(() => null);
|
|
304
|
+
if (!source)
|
|
305
|
+
return base;
|
|
306
|
+
return {
|
|
307
|
+
...base,
|
|
308
|
+
latest: source.updatedAt,
|
|
309
|
+
updateAvailable: source.updatedAt !== pin.version,
|
|
310
|
+
};
|
|
311
|
+
}));
|
|
312
|
+
statusCache.set(deckDir, { at: Date.now(), value });
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
// Taking or reverting an update makes the cached answer wrong immediately.
|
|
316
|
+
function clearStatusCache(deckDir) {
|
|
317
|
+
statusCache.delete(deckDir);
|
|
318
|
+
}
|
|
319
|
+
// Place one deck's source at `imports/<alias>`, replacing whatever is there, and
|
|
320
|
+
// record the pin. The tree is unpacked to a temp dir first so a fetch or tar that
|
|
321
|
+
// fails partway leaves the deck untouched, and locked read-only afterwards.
|
|
322
|
+
// The version an import is being replaced from, kept so an update can be undone
|
|
323
|
+
// without the server's help -- it only ever has the latest archive, so once a
|
|
324
|
+
// tree is overwritten the bytes are gone. One backup per import: enough for the
|
|
325
|
+
// "update, playtest, undo if it broke something" loop, which is the case this
|
|
326
|
+
// exists for.
|
|
327
|
+
const BACKUPS_DIR = path.join('.castle', 'import-backups');
|
|
328
|
+
function backupPath(deckDir, alias) {
|
|
329
|
+
return path.join(deckDir, BACKUPS_DIR, alias);
|
|
330
|
+
}
|
|
331
|
+
function backupImport(deckDir, alias) {
|
|
332
|
+
const src = path.join(deckDir, IMPORTS_DIR, alias);
|
|
333
|
+
if (!fs.existsSync(src))
|
|
334
|
+
return;
|
|
335
|
+
const dest = backupPath(deckDir, alias);
|
|
336
|
+
if (fs.existsSync(dest)) {
|
|
337
|
+
unlockImportTree(dest);
|
|
338
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
339
|
+
}
|
|
340
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
341
|
+
fs.cpSync(src, dest, { recursive: true, verbatimSymlinks: true });
|
|
342
|
+
const pin = (readCastleJson(deckDir)?.imports ?? {})[alias];
|
|
343
|
+
if (pin) {
|
|
344
|
+
fs.writeFileSync(path.join(dest, '.castle-import-pin.json'), JSON.stringify(pin, null, 2), 'utf-8');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function placeImport(deckDir, alias, deckId, source, via) {
|
|
348
|
+
const destDir = path.join(deckDir, IMPORTS_DIR, alias);
|
|
349
|
+
const replaced = fs.existsSync(destDir);
|
|
350
|
+
if (replaced) {
|
|
351
|
+
backupImport(deckDir, alias);
|
|
352
|
+
unlockImportTree(destDir);
|
|
353
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
await fetchDeckSourceInto(source.archiveUrl, destDir);
|
|
356
|
+
lockImportTree(destDir);
|
|
357
|
+
clearStatusCache(deckDir);
|
|
358
|
+
writeImportPin(deckDir, alias, {
|
|
359
|
+
deckId,
|
|
360
|
+
version: source.updatedAt,
|
|
361
|
+
...(via ? { via } : {}),
|
|
362
|
+
});
|
|
363
|
+
return { replaced };
|
|
364
|
+
}
|
|
365
|
+
// The imports of an import. A deck's own imports/ is left out of its archive, so
|
|
366
|
+
// what arrives is its pins -- this follows them, flattening everything into ONE
|
|
367
|
+
// imports/ at the deck root: a dependency's code refers to its own dependencies
|
|
368
|
+
// by the path they sit at in the deck being served, and there is only one deck
|
|
369
|
+
// being served.
|
|
370
|
+
//
|
|
371
|
+
// The alias is the one the depending deck recorded, because that is what its
|
|
372
|
+
// refs are written against. Deterministic naming (`<author>.<deck>`) means two
|
|
373
|
+
// decks depending on the same thing normally agree on the name by themselves;
|
|
374
|
+
// where they don't, first one in wins and the second is reported rather than
|
|
375
|
+
// silently renamed, since renaming would break the refs of whoever named it.
|
|
376
|
+
async function addTransitiveImports(deckDir, viaAlias, seen) {
|
|
377
|
+
const viaDir = path.join(deckDir, IMPORTS_DIR, viaAlias);
|
|
378
|
+
const pins = (readCastleJson(viaDir)?.imports ?? {});
|
|
379
|
+
const added = [];
|
|
380
|
+
for (const [alias, pin] of Object.entries(pins)) {
|
|
381
|
+
if (!pin?.deckId || seen.has(pin.deckId))
|
|
382
|
+
continue;
|
|
383
|
+
const existing = (readCastleJson(deckDir)?.imports ?? {});
|
|
384
|
+
const holder = existing[alias];
|
|
385
|
+
if (holder && holder.deckId !== pin.deckId) {
|
|
386
|
+
console.warn(`"${viaAlias}" wants ${pin.deckId} as "${alias}", which is already ${holder.deckId} here -- skipping; ` +
|
|
387
|
+
`"${viaAlias}" may not work until one of them is re-imported under another name.`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (holder) {
|
|
391
|
+
seen.add(pin.deckId);
|
|
392
|
+
continue; // already have this one, at the name it expects
|
|
393
|
+
}
|
|
394
|
+
const source = await api.webDeckSource(pin.deckId).catch(() => null);
|
|
395
|
+
if (!source) {
|
|
396
|
+
console.warn(`"${viaAlias}" needs ${pin.deckId} ("${alias}"), which has no source on the server.`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
seen.add(pin.deckId);
|
|
400
|
+
await placeImport(deckDir, alias, pin.deckId, source, viaAlias);
|
|
401
|
+
added.push(alias);
|
|
402
|
+
added.push(...(await addTransitiveImports(deckDir, alias, seen)));
|
|
403
|
+
}
|
|
404
|
+
return added;
|
|
405
|
+
}
|
|
406
|
+
// Every deckId this deck already has, so a transitive walk doesn't refetch or
|
|
407
|
+
// loop on a cycle.
|
|
408
|
+
function importedDeckIds(deckDir) {
|
|
409
|
+
const pins = (readCastleJson(deckDir)?.imports ?? {});
|
|
410
|
+
return new Set(Object.values(pins).map((p) => p?.deckId).filter((id) => !!id));
|
|
411
|
+
}
|
|
412
|
+
export async function addImport(dir, options = {}) {
|
|
413
|
+
const targetDir = path.resolve(dir);
|
|
414
|
+
const deckId = options.deckId;
|
|
415
|
+
if (!deckId) {
|
|
416
|
+
console.error('No deck id. Usage: castle-web add-import <deckId> [dir] [--as ALIAS]');
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
419
|
+
if (!fs.existsSync(targetDir)) {
|
|
420
|
+
console.error(`No such directory: ${targetDir}`);
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
if (readCastleJson(targetDir)?.deckId === deckId) {
|
|
424
|
+
console.error(`Deck ${deckId} is this deck -- a deck can't import itself.`);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
const [source, meta] = await Promise.all([
|
|
428
|
+
api.webDeckSource(deckId),
|
|
429
|
+
api.deckMeta(deckId).catch(() => null),
|
|
430
|
+
]);
|
|
431
|
+
if (!source) {
|
|
432
|
+
console.error(`No source archive on the server for deck ${deckId}.`);
|
|
433
|
+
console.error(`Its owner needs to run \`castle-web save-deck\` before it can be imported.`);
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
const alias = options.alias ? slugifyGivenAlias(options.alias) : qualifiedAlias(meta, deckId);
|
|
437
|
+
if (!alias) {
|
|
438
|
+
console.error(`Could not derive a usable name from --as; pick one with letters or digits.`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
const { replaced } = await placeImport(targetDir, alias, deckId, source);
|
|
442
|
+
console.log(`${replaced ? 'Updated' : 'Imported'} ${deckId} at ${IMPORTS_DIR}/${alias} (read-only on disk).`);
|
|
443
|
+
const seen = importedDeckIds(targetDir);
|
|
444
|
+
const transitive = await addTransitiveImports(targetDir, alias, seen);
|
|
445
|
+
for (const t of transitive)
|
|
446
|
+
console.log(`Also imported ${IMPORTS_DIR}/${t} (needed by ${alias})`);
|
|
447
|
+
const added = syncImportDependencies(targetDir);
|
|
448
|
+
for (const dep of added)
|
|
449
|
+
console.log(`Added dependency ${dep}`);
|
|
450
|
+
if (added.length > 0)
|
|
451
|
+
console.log('Run `castle-web install` to install them.');
|
|
452
|
+
}
|
|
453
|
+
// `castle-web update-import [alias] [dir]`: re-fetch an import (or all of them)
|
|
454
|
+
// at the version the server has now. Updates happen while editing, never on
|
|
455
|
+
// someone else's behalf at play time -- the deck that gets published is one that
|
|
456
|
+
// was run after the update, and `--revert` puts the previous version back if it
|
|
457
|
+
// wasn't.
|
|
458
|
+
export async function updateImport(dir, options = {}) {
|
|
459
|
+
const deckDir = path.resolve(dir);
|
|
460
|
+
const pins = (readCastleJson(deckDir)?.imports ?? {});
|
|
461
|
+
const aliases = options.alias ? [options.alias] : Object.keys(pins);
|
|
462
|
+
if (aliases.length === 0) {
|
|
463
|
+
console.log('This deck has no imports.');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
for (const alias of aliases) {
|
|
467
|
+
// Thrown, not exited: this runs inside the serve when the editor takes an
|
|
468
|
+
// update, and a bad name must not take the server down with it. The CLI's
|
|
469
|
+
// top-level handler turns it back into a message and a non-zero exit.
|
|
470
|
+
if (!pins[alias])
|
|
471
|
+
throw new Error(`No import named "${alias}" in this deck.`);
|
|
472
|
+
}
|
|
473
|
+
if (options.revert) {
|
|
474
|
+
for (const alias of aliases)
|
|
475
|
+
revertImport(deckDir, alias);
|
|
476
|
+
syncImportDependencies(deckDir);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
let changed = false;
|
|
480
|
+
for (const alias of aliases) {
|
|
481
|
+
const pin = pins[alias];
|
|
482
|
+
if (!pin.deckId) {
|
|
483
|
+
// A kit copied from the CLI updates with the CLI, not from the server.
|
|
484
|
+
if (options.alias)
|
|
485
|
+
console.log(`"${alias}" came with this CLI; it updates when the CLI does.`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const source = await api.webDeckSource(pin.deckId).catch(() => null);
|
|
489
|
+
if (!source) {
|
|
490
|
+
console.warn(`Could not check "${alias}" (${pin.deckId}); it has no source on the server.`);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (source.updatedAt === pin.version) {
|
|
494
|
+
console.log(`${alias} is up to date.`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (options.check) {
|
|
498
|
+
console.log(`${alias}: update available (have ${pin.version}, latest ${source.updatedAt})`);
|
|
499
|
+
changed = true;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
await placeImport(deckDir, alias, pin.deckId, source, pin.via);
|
|
503
|
+
console.log(`Updated ${alias} (${pin.version} -> ${source.updatedAt})`);
|
|
504
|
+
// Its own dependencies may have moved too.
|
|
505
|
+
const transitive = await addTransitiveImports(deckDir, alias, importedDeckIds(deckDir));
|
|
506
|
+
for (const t of transitive)
|
|
507
|
+
console.log(`Also imported ${IMPORTS_DIR}/${t} (needed by ${alias})`);
|
|
508
|
+
changed = true;
|
|
509
|
+
}
|
|
510
|
+
if (!changed)
|
|
511
|
+
return;
|
|
512
|
+
if (options.check) {
|
|
513
|
+
console.log('Run `castle-web update-import` to take them.');
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const added = syncImportDependencies(deckDir);
|
|
517
|
+
for (const dep of added)
|
|
518
|
+
console.log(`Added dependency ${dep}`);
|
|
519
|
+
console.log('Play the deck to check the update before saving; `--revert` puts the old version back.');
|
|
520
|
+
}
|
|
521
|
+
// Restore the copy taken when this import was last replaced.
|
|
522
|
+
function revertImport(deckDir, alias) {
|
|
523
|
+
const backup = backupPath(deckDir, alias);
|
|
524
|
+
if (!fs.existsSync(backup)) {
|
|
525
|
+
console.error(`No previous version of "${alias}" is kept here -- nothing to revert to.`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const destDir = path.join(deckDir, IMPORTS_DIR, alias);
|
|
529
|
+
if (fs.existsSync(destDir)) {
|
|
530
|
+
unlockImportTree(destDir);
|
|
531
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
fs.cpSync(backup, destDir, { recursive: true, verbatimSymlinks: true });
|
|
534
|
+
const pinFile = path.join(destDir, '.castle-import-pin.json');
|
|
535
|
+
if (fs.existsSync(pinFile)) {
|
|
536
|
+
try {
|
|
537
|
+
writeImportPin(deckDir, alias, JSON.parse(fs.readFileSync(pinFile, 'utf-8')));
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
/* an unreadable pin file just means the version stays as recorded */
|
|
541
|
+
}
|
|
542
|
+
fs.rmSync(pinFile, { force: true });
|
|
543
|
+
}
|
|
544
|
+
unlockImportTree(backup);
|
|
545
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
546
|
+
lockImportTree(destDir);
|
|
547
|
+
clearStatusCache(deckDir);
|
|
548
|
+
console.log(`Reverted ${alias} to its previous version.`);
|
|
549
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,9 +5,9 @@ import { login } from './login.js';
|
|
|
5
5
|
import { serve } from './serve.js';
|
|
6
6
|
import { saveDeck } from './save-deck.js';
|
|
7
7
|
import { getDeck } from './get-deck.js';
|
|
8
|
+
import { addImport, updateImport } from './imports.js';
|
|
8
9
|
import { init } from './init.js';
|
|
9
10
|
import { install } from './install.js';
|
|
10
|
-
import { pull } from './pull.js';
|
|
11
11
|
import { connectWS, savePreviewImage, savePreviewIfNeeded, takeScreenshot } from './preview.js';
|
|
12
12
|
const args = process.argv.slice(2);
|
|
13
13
|
const command = args[0];
|
|
@@ -20,6 +20,7 @@ const FLAGS_WITH_VALUES = new Set([
|
|
|
20
20
|
'--title',
|
|
21
21
|
'--caption',
|
|
22
22
|
'--visibility',
|
|
23
|
+
'--as',
|
|
23
24
|
]);
|
|
24
25
|
function findPositionalDir() {
|
|
25
26
|
for (let i = 1; i < args.length; i++) {
|
|
@@ -32,6 +33,21 @@ function findPositionalDir() {
|
|
|
32
33
|
}
|
|
33
34
|
return '.';
|
|
34
35
|
}
|
|
36
|
+
// Every positional after the command, in order (flags and their values skipped).
|
|
37
|
+
// `import` takes two (the deck id, then the importing deck's dir); every other
|
|
38
|
+
// command takes at most one and uses findPositionalDir.
|
|
39
|
+
function readPositionals() {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let i = 1; i < args.length; i++) {
|
|
42
|
+
if (args[i].startsWith('--')) {
|
|
43
|
+
if (FLAGS_WITH_VALUES.has(args[i]))
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out.push(args[i]);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
35
51
|
function getFlagValue(flag) {
|
|
36
52
|
const idx = args.indexOf(flag);
|
|
37
53
|
return idx >= 0 ? args[idx + 1] : undefined;
|
|
@@ -65,8 +81,9 @@ function usage() {
|
|
|
65
81
|
castle-web screenshot [--out FILE] [--port PORT]
|
|
66
82
|
castle-web save-preview-image [dir] [--port PORT] [--no-restart]
|
|
67
83
|
castle-web save-deck [dir] [--title TITLE] [--caption TEXT] [--visibility unlisted|private]
|
|
68
|
-
castle-web get-deck
|
|
69
|
-
castle-web
|
|
84
|
+
castle-web get-deck [dir] [--deck-id ID] [--force] (replaces the source if the deck is already there)
|
|
85
|
+
castle-web add-import <deckId> [dir] [--as ALIAS] (adds another deck as a read-only dependency in imports/)
|
|
86
|
+
castle-web update-import [alias] [dir] [--check] [--revert] (re-fetches imports; no alias means all)
|
|
70
87
|
castle-web install [dir]
|
|
71
88
|
castle-web login
|
|
72
89
|
|
|
@@ -112,24 +129,37 @@ async function main() {
|
|
|
112
129
|
break;
|
|
113
130
|
}
|
|
114
131
|
case 'get-deck': {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|
|
120
|
-
const deckId = getFlagValue('--deck-id');
|
|
121
|
-
await getDeck(dir, { deckId });
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
case 'pull': {
|
|
125
|
-
await pull(findPositionalDir(), {
|
|
132
|
+
// `.` is allowed: refreshing the deck you're standing in is the in-place case,
|
|
133
|
+
// and a cwd that isn't a deck fails on deckId resolution anyway.
|
|
134
|
+
await getDeck(findPositionalDir(), {
|
|
126
135
|
deckId: getFlagValue('--deck-id'),
|
|
127
136
|
force: hasFlag('--force'),
|
|
128
137
|
});
|
|
129
138
|
break;
|
|
130
139
|
}
|
|
140
|
+
case 'add-import': {
|
|
141
|
+
// The deck id is the first positional; the importing deck's dir is an
|
|
142
|
+
// optional second, defaulting to the cwd.
|
|
143
|
+
const positionals = readPositionals();
|
|
144
|
+
await addImport(positionals[1] ?? '.', {
|
|
145
|
+
deckId: getFlagValue('--deck-id') ?? positionals[0],
|
|
146
|
+
alias: getFlagValue('--as'),
|
|
147
|
+
});
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case 'update-import': {
|
|
151
|
+
// `castle-web update-import [alias] [dir]` -- no alias updates every import.
|
|
152
|
+
const positionals = readPositionals();
|
|
153
|
+
const alias = positionals[0];
|
|
154
|
+
await updateImport(positionals[1] ?? (alias && !alias.includes('/') ? '.' : alias) ?? '.', {
|
|
155
|
+
alias,
|
|
156
|
+
check: hasFlag('--check'),
|
|
157
|
+
revert: hasFlag('--revert'),
|
|
158
|
+
});
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
131
161
|
case 'install': {
|
|
132
|
-
install(findPositionalDir());
|
|
162
|
+
await install(findPositionalDir());
|
|
133
163
|
break;
|
|
134
164
|
}
|
|
135
165
|
case 'restart': {
|