castle-web-cli 0.4.92 → 0.4.93

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.
Files changed (42) hide show
  1. package/dist/agent.js +141 -22
  2. package/dist/api.d.ts +8 -0
  3. package/dist/api.js +10 -0
  4. package/dist/bundle.js +2 -2
  5. package/dist/get-deck.d.ts +1 -1
  6. package/dist/get-deck.js +93 -22
  7. package/dist/ide.js +123 -15
  8. package/dist/imports.d.ts +22 -0
  9. package/dist/imports.js +549 -0
  10. package/dist/index.js +45 -15
  11. package/dist/init.js +169 -1
  12. package/dist/install.d.ts +1 -1
  13. package/dist/install.js +14 -1
  14. package/dist/metering.d.ts +1 -0
  15. package/dist/metering.js +1 -1
  16. package/dist/native/loop.js +1 -0
  17. package/dist/native/openrouter.d.ts +1 -0
  18. package/dist/native/openrouter.js +13 -6
  19. package/dist/native/types.d.ts +1 -0
  20. package/dist/normalize.js +4 -0
  21. package/dist/openrouter-catalog.d.ts +3 -1
  22. package/dist/openrouter-catalog.js +15 -10
  23. package/dist/save-deck.d.ts +2 -0
  24. package/dist/save-deck.js +25 -19
  25. package/dist/serve.js +2 -2
  26. package/dist/shell/assets/{index-DSIr52Kl.css → index-CWNH9QiB.css} +1 -1
  27. package/dist/shell/assets/{index-BFCG4tLs.js → index-_C2BvstY.js} +21 -21
  28. package/dist/shell/index.html +2 -2
  29. package/dist/vitePlugins.d.ts +1 -0
  30. package/dist/vitePlugins.js +33 -0
  31. package/kits/basic-2d/CLAUDE.md +20 -0
  32. package/kits/basic-2d/behaviors/Sprite.jsx +6 -1
  33. package/kits/basic-2d/editors/BlueprintLibrary.jsx +14 -8
  34. package/kits/basic-2d/editors/behaviorRegistry.js +8 -2
  35. package/kits/basic-2d/engine/behaviorExtensions.js +5 -1
  36. package/kits/basic-2d/engine/blueprint.js +39 -3
  37. package/kits/basic-2d/engine/files.js +26 -5
  38. package/kits/basic-2d/engine/scene.js +4 -1
  39. package/kits/basic-2d/engine/systemRegistry.js +5 -1
  40. package/package.json +1 -1
  41. package/dist/pull.d.ts +0 -4
  42. package/dist/pull.js +0 -119
@@ -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 <dir> [--deck-id ID]
69
- castle-web pull [dir] [--deck-id ID] [--force]
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
- const dir = findPositionalDir();
116
- if (dir === '.') {
117
- console.error('Usage: castle-web get-deck <dir> [--deck-id ID]');
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': {