create-glanceway-source 1.0.0 → 1.2.0

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/package.json CHANGED
@@ -1,34 +1,32 @@
1
1
  {
2
2
  "name": "create-glanceway-source",
3
- "version": "1.0.0",
4
- "description": "Create a new Glanceway information source with TypeScript",
3
+ "version": "1.2.0",
4
+ "description": "Scaffold a standalone Glanceway source project",
5
5
  "type": "module",
6
6
  "bin": {
7
- "create-glanceway-source": "./dist/index.js"
7
+ "create-glanceway-source": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc"
8
11
  },
9
12
  "files": [
10
13
  "dist",
11
- "template"
14
+ "templates"
12
15
  ],
13
- "scripts": {
14
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node'",
15
- "dev": "npm run build && node dist/index.js"
16
+ "dependencies": {
17
+ "prompts": "^2.4.2"
16
18
  },
17
- "keywords": [
18
- "glanceway",
19
- "source",
20
- "create",
21
- "cli",
22
- "template"
23
- ],
24
- "author": "codytseng",
25
- "license": "MIT",
26
19
  "devDependencies": {
27
- "@types/node": "^20.10.0",
28
- "esbuild": "^0.19.0",
29
- "typescript": "^5.3.3"
20
+ "@types/archiver": "^7.0.0",
21
+ "@types/node": "^22.0.0",
22
+ "@types/prompts": "^2.4.9",
23
+ "archiver": "^7.0.1",
24
+ "esbuild": "^0.27.3",
25
+ "typescript": "^5.3.3",
26
+ "yaml": "^2.8.2"
30
27
  },
31
28
  "engines": {
32
29
  "node": ">=18"
33
- }
34
- }
30
+ },
31
+ "license": "MIT"
32
+ }
@@ -0,0 +1,24 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build outputs
5
+ dist/
6
+
7
+ # IDE
8
+ .idea/
9
+ .vscode/
10
+ *.swp
11
+ *.swo
12
+ .claude/
13
+
14
+ # OS
15
+ .DS_Store
16
+ Thumbs.db
17
+
18
+ # Logs
19
+ *.log
20
+ npm-debug.log*
21
+
22
+ # Environment
23
+ .env
24
+ .env.local
@@ -0,0 +1,103 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import * as esbuild from "esbuild";
5
+ import archiver from "archiver";
6
+
7
+ const ROOT_DIR = process.cwd();
8
+ const DIST_DIR = path.join(ROOT_DIR, "dist");
9
+
10
+ async function compileTypeScript(indexPath: string): Promise<string> {
11
+ const result = await esbuild.build({
12
+ entryPoints: [indexPath],
13
+ bundle: true,
14
+ platform: "neutral",
15
+ format: "iife",
16
+ globalName: "_source",
17
+ write: false,
18
+ external: ["node:*"],
19
+ footer: { js: "module.exports = _source.default;" },
20
+ });
21
+
22
+ return result.outputFiles[0].text;
23
+ }
24
+
25
+ async function createZip(
26
+ outputPath: string,
27
+ files: { name: string; content: string | Buffer }[],
28
+ ): Promise<void> {
29
+ return new Promise((resolve, reject) => {
30
+ const output = fs.createWriteStream(outputPath);
31
+ const archive = archiver("zip", { zlib: { level: 9 } });
32
+
33
+ output.on("close", resolve);
34
+ archive.on("error", reject);
35
+
36
+ archive.pipe(output);
37
+
38
+ for (const file of files) {
39
+ archive.append(file.content, { name: file.name });
40
+ }
41
+
42
+ archive.finalize();
43
+ });
44
+ }
45
+
46
+ async function main() {
47
+ const manifestPath = path.join(ROOT_DIR, "manifest.yaml");
48
+ const indexPath = path.join(ROOT_DIR, "src", "index.ts");
49
+
50
+ if (!fs.existsSync(manifestPath)) {
51
+ console.error("Error: manifest.yaml not found");
52
+ process.exit(1);
53
+ }
54
+
55
+ if (!fs.existsSync(indexPath)) {
56
+ console.error("Error: src/index.ts not found");
57
+ process.exit(1);
58
+ }
59
+
60
+ // Read manifest for version
61
+ const manifestContent = fs.readFileSync(manifestPath, "utf-8");
62
+ const manifest = parseYaml(manifestContent);
63
+ const version = manifest.version || "1.0.0";
64
+
65
+ console.log(`Building source v${version}...\n`);
66
+
67
+ // Compile TypeScript
68
+ console.log("Compiling TypeScript...");
69
+ const compiledJs = await compileTypeScript(indexPath);
70
+
71
+ // Create dist directory
72
+ fs.mkdirSync(DIST_DIR, { recursive: true });
73
+
74
+ // Write compiled JS
75
+ const jsPath = path.join(DIST_DIR, "index.js");
76
+ fs.writeFileSync(jsPath, compiledJs);
77
+ console.log(" Created dist/index.js");
78
+
79
+ // Copy manifest
80
+ const distManifestPath = path.join(DIST_DIR, "manifest.yaml");
81
+ fs.copyFileSync(manifestPath, distManifestPath);
82
+ console.log(" Created dist/manifest.yaml");
83
+
84
+ // Create versioned .gwsrc package
85
+ const versionedPath = path.join(DIST_DIR, `${version}.gwsrc`);
86
+ await createZip(versionedPath, [
87
+ { name: "manifest.yaml", content: manifestContent },
88
+ { name: "index.js", content: compiledJs },
89
+ ]);
90
+ console.log(` Created dist/${version}.gwsrc`);
91
+
92
+ // Create latest.gwsrc
93
+ const latestPath = path.join(DIST_DIR, "latest.gwsrc");
94
+ fs.copyFileSync(versionedPath, latestPath);
95
+ console.log(" Created dist/latest.gwsrc");
96
+
97
+ console.log("\nBuild complete!");
98
+ }
99
+
100
+ main().catch((err) => {
101
+ console.error(err);
102
+ process.exit(1);
103
+ });
@@ -0,0 +1,475 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import * as esbuild from "esbuild";
5
+
6
+ // ─── ANSI Colors ───────────────────────────────────────────────────────────
7
+
8
+ const color = {
9
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
10
+ red: (s: string) => `\x1b[31m${s}\x1b[0m`,
11
+ yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
12
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
13
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
14
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
15
+ };
16
+
17
+ // ─── Types ─────────────────────────────────────────────────────────────────
18
+
19
+ const ROOT_DIR = process.cwd();
20
+
21
+ interface ManifestConfig {
22
+ key: string;
23
+ name: string;
24
+ type: string;
25
+ required?: boolean;
26
+ default?: unknown;
27
+ description?: string;
28
+ }
29
+
30
+ interface EmittedItem {
31
+ id?: unknown;
32
+ title?: unknown;
33
+ subtitle?: unknown;
34
+ url?: unknown;
35
+ timestamp?: unknown;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ // ─── Arg Parsing ───────────────────────────────────────────────────────────
40
+
41
+ function parseArgs(): { config: Record<string, string> } {
42
+ const args = process.argv.slice(2);
43
+ const config: Record<string, string> = {};
44
+
45
+ for (let i = 0; i < args.length; i++) {
46
+ if (args[i] === "--config" && args[i + 1]) {
47
+ const val = args[++i];
48
+ const eqIdx = val.indexOf("=");
49
+ if (eqIdx > 0) {
50
+ config[val.slice(0, eqIdx)] = val.slice(eqIdx + 1);
51
+ }
52
+ }
53
+ }
54
+
55
+ return { config };
56
+ }
57
+
58
+ // ─── Config Resolution ─────────────────────────────────────────────────────
59
+
60
+ function resolveConfig(
61
+ configEntries: ManifestConfig[] | undefined,
62
+ overrides: Record<string, string>,
63
+ ): { resolved: Record<string, unknown>; warnings: string[] } | null {
64
+ const resolved: Record<string, unknown> = {};
65
+ const warnings: string[] = [];
66
+
67
+ if (!configEntries || configEntries.length === 0) {
68
+ return { resolved, warnings };
69
+ }
70
+
71
+ for (const entry of configEntries) {
72
+ const override = overrides[entry.key];
73
+
74
+ if (override !== undefined) {
75
+ if (entry.type === "list") {
76
+ resolved[entry.key] = override.split(",").map((s) => s.trim());
77
+ } else if (entry.type === "boolean") {
78
+ resolved[entry.key] = override === "true" || override === "1";
79
+ } else if (entry.type === "number") {
80
+ resolved[entry.key] = Number(override);
81
+ } else {
82
+ resolved[entry.key] = override;
83
+ }
84
+ } else if (entry.default !== undefined) {
85
+ resolved[entry.key] = entry.default;
86
+ } else if (entry.required) {
87
+ warnings.push(
88
+ `Required config "${entry.key}" has no default. Use --config ${entry.key}=VALUE to provide a value.`,
89
+ );
90
+ return null;
91
+ }
92
+ }
93
+
94
+ return { resolved, warnings };
95
+ }
96
+
97
+ // ─── Item Validation ───────────────────────────────────────────────────────
98
+
99
+ const MAX_MESSAGES = 5;
100
+
101
+ function validateItems(
102
+ items: EmittedItem[],
103
+ phase: string,
104
+ ): { errors: string[]; warnings: string[] } {
105
+ const errors: string[] = [];
106
+ const warnings: string[] = [];
107
+
108
+ if (items.length > 500) {
109
+ errors.push(`${phase}: emitted ${items.length} items (max 500)`);
110
+ }
111
+
112
+ if (items.length === 0) {
113
+ warnings.push(`${phase}: zero items emitted`);
114
+ return { errors, warnings };
115
+ }
116
+
117
+ const seenIds = new Set<string>();
118
+ let errorCount = 0;
119
+ let warningCount = 0;
120
+
121
+ for (let i = 0; i < items.length; i++) {
122
+ const item = items[i];
123
+ const prefix = `${phase} item[${i}]`;
124
+
125
+ if (!item.id || typeof item.id !== "string" || item.id.trim() === "") {
126
+ if (errorCount < MAX_MESSAGES) {
127
+ errors.push(`${prefix}: "id" is missing or empty`);
128
+ }
129
+ errorCount++;
130
+ continue;
131
+ }
132
+
133
+ const hasTitle =
134
+ item.title && typeof item.title === "string" && item.title.trim() !== "";
135
+ const hasSubtitle =
136
+ item.subtitle &&
137
+ typeof item.subtitle === "string" &&
138
+ item.subtitle.trim() !== "";
139
+ if (!hasTitle && !hasSubtitle) {
140
+ if (errorCount < MAX_MESSAGES) {
141
+ errors.push(
142
+ `${prefix}: must have at least one of "title" or "subtitle"`,
143
+ );
144
+ }
145
+ errorCount++;
146
+ continue;
147
+ }
148
+
149
+ if (seenIds.has(item.id)) {
150
+ if (warningCount < MAX_MESSAGES) {
151
+ warnings.push(`${prefix}: duplicate id "${item.id}"`);
152
+ }
153
+ warningCount++;
154
+ }
155
+ seenIds.add(item.id);
156
+
157
+ if (item.subtitle !== undefined && typeof item.subtitle !== "string") {
158
+ if (warningCount < MAX_MESSAGES) {
159
+ warnings.push(`${prefix}: "subtitle" should be a string`);
160
+ }
161
+ warningCount++;
162
+ }
163
+
164
+ if (item.url !== undefined) {
165
+ if (typeof item.url !== "string") {
166
+ if (warningCount < MAX_MESSAGES) {
167
+ warnings.push(`${prefix}: "url" should be a string`);
168
+ }
169
+ warningCount++;
170
+ } else if (
171
+ !item.url.startsWith("http://") &&
172
+ !item.url.startsWith("https://")
173
+ ) {
174
+ if (warningCount < MAX_MESSAGES) {
175
+ warnings.push(
176
+ `${prefix}: "url" does not start with http(s)://`,
177
+ );
178
+ }
179
+ warningCount++;
180
+ }
181
+ }
182
+ }
183
+
184
+ if (errorCount > MAX_MESSAGES) {
185
+ errors.push(`... and ${errorCount - MAX_MESSAGES} more errors`);
186
+ }
187
+ if (warningCount > MAX_MESSAGES) {
188
+ warnings.push(`... and ${warningCount - MAX_MESSAGES} more warnings`);
189
+ }
190
+
191
+ return { errors, warnings };
192
+ }
193
+
194
+ // ─── Mock API ──────────────────────────────────────────────────────────────
195
+
196
+ function createMockApi(
197
+ config: Record<string, unknown>,
198
+ ): {
199
+ api: any;
200
+ getEmitted: () => EmittedItem[][];
201
+ } {
202
+ const emitted: EmittedItem[][] = [];
203
+ const storage = new Map<string, string>();
204
+
205
+ const api = {
206
+ emit(items: EmittedItem[]) {
207
+ emitted.push(items);
208
+ },
209
+
210
+ async fetch(url: string, options?: any) {
211
+ const timeout = options?.timeout ?? 30000;
212
+ const controller = new AbortController();
213
+ const timer = setTimeout(() => controller.abort(), timeout);
214
+
215
+ try {
216
+ const resp = await globalThis.fetch(url, {
217
+ method: options?.method ?? "GET",
218
+ headers: options?.headers,
219
+ body: options?.body,
220
+ signal: controller.signal,
221
+ });
222
+ clearTimeout(timer);
223
+
224
+ const text = await resp.text();
225
+ const headers: Record<string, string> = {};
226
+ resp.headers.forEach((v, k) => {
227
+ headers[k] = v;
228
+ });
229
+
230
+ let json: unknown;
231
+ try {
232
+ json = JSON.parse(text);
233
+ } catch {
234
+ // not JSON
235
+ }
236
+
237
+ return {
238
+ ok: resp.ok,
239
+ status: resp.status,
240
+ headers,
241
+ text,
242
+ json,
243
+ };
244
+ } catch (err: any) {
245
+ clearTimeout(timer);
246
+ return {
247
+ ok: false,
248
+ status: 0,
249
+ headers: {},
250
+ text: "",
251
+ error: err?.message ?? String(err),
252
+ };
253
+ }
254
+ },
255
+
256
+ log(level: string, message: string) {
257
+ console.log(color.dim(` [source] ${level}: ${message}`));
258
+ },
259
+
260
+ storage: {
261
+ get(key: string) {
262
+ return storage.get(key);
263
+ },
264
+ set(key: string, value: string) {
265
+ storage.set(key, value);
266
+ },
267
+ },
268
+
269
+ config: {
270
+ get(key: string) {
271
+ return config[key];
272
+ },
273
+ getAll() {
274
+ return { ...config };
275
+ },
276
+ },
277
+
278
+ websocket: {
279
+ connect() {
280
+ throw new Error("WebSocket is not supported in test mode");
281
+ },
282
+ },
283
+
284
+ appVersion: "99.0.0",
285
+ };
286
+
287
+ return { api, getEmitted: () => emitted };
288
+ }
289
+
290
+ // ─── Main ──────────────────────────────────────────────────────────────────
291
+
292
+ async function main() {
293
+ const args = parseArgs();
294
+
295
+ const manifestPath = path.join(ROOT_DIR, "manifest.yaml");
296
+ const indexPath = path.join(ROOT_DIR, "src", "index.ts");
297
+
298
+ if (!fs.existsSync(manifestPath)) {
299
+ console.error("Error: manifest.yaml not found");
300
+ process.exit(1);
301
+ }
302
+
303
+ if (!fs.existsSync(indexPath)) {
304
+ console.error("Error: src/index.ts not found");
305
+ process.exit(1);
306
+ }
307
+
308
+ // Read manifest and resolve config
309
+ const manifestContent = fs.readFileSync(manifestPath, "utf-8");
310
+ const manifest = parseYaml(manifestContent);
311
+ const configResult = resolveConfig(manifest.config, args.config);
312
+
313
+ if (!configResult) {
314
+ const warningMessages: string[] = [];
315
+ if (manifest.config) {
316
+ for (const entry of manifest.config as ManifestConfig[]) {
317
+ if (
318
+ args.config[entry.key] === undefined &&
319
+ entry.default === undefined &&
320
+ entry.required
321
+ ) {
322
+ warningMessages.push(
323
+ `Required config "${entry.key}" has no default. Use --config ${entry.key}=VALUE to provide a value.`,
324
+ );
325
+ }
326
+ }
327
+ }
328
+ console.log(color.yellow("SKIP") + " Missing required config:");
329
+ for (const w of warningMessages) {
330
+ console.log(` ${color.yellow("warning")}: ${w}`);
331
+ }
332
+ process.exit(0);
333
+ }
334
+
335
+ const { resolved: config, warnings } = configResult;
336
+
337
+ console.log(`Testing source...\n`);
338
+
339
+ // Compile TypeScript
340
+ let compiledJs: string;
341
+ try {
342
+ const result = await esbuild.build({
343
+ entryPoints: [indexPath],
344
+ bundle: true,
345
+ platform: "neutral",
346
+ format: "iife",
347
+ globalName: "_source",
348
+ write: false,
349
+ external: ["node:*"],
350
+ footer: { js: "module.exports = _source.default;" },
351
+ });
352
+ compiledJs = result.outputFiles[0].text;
353
+ console.log(" Compiled TypeScript successfully");
354
+ } catch (err: any) {
355
+ console.log(` ${color.red("FAIL")} Compilation failed: ${err.message ?? err}`);
356
+ process.exit(1);
357
+ }
358
+
359
+ // Execute compiled code
360
+ const { api, getEmitted } = createMockApi(config);
361
+ const start = Date.now();
362
+
363
+ let sourceMethods: any;
364
+ try {
365
+ const moduleObj = { exports: {} as any };
366
+ const fn = new Function("module", "exports", compiledJs);
367
+ fn(moduleObj, moduleObj.exports);
368
+
369
+ const factory = moduleObj.exports;
370
+ if (typeof factory !== "function") {
371
+ console.log(` ${color.red("FAIL")} Default export is not a function`);
372
+ process.exit(1);
373
+ }
374
+
375
+ sourceMethods = await Promise.race([
376
+ factory(api),
377
+ new Promise((_, reject) =>
378
+ setTimeout(() => reject(new Error("Start phase timed out (30s)")), 30000),
379
+ ),
380
+ ]);
381
+ } catch (err: any) {
382
+ console.log(` ${color.red("FAIL")} Start phase error: ${err.message ?? err}`);
383
+ process.exit(1);
384
+ }
385
+
386
+ // Validate start phase items
387
+ const startEmitted = getEmitted();
388
+ const startItems =
389
+ startEmitted.length > 0 ? startEmitted[startEmitted.length - 1] : [];
390
+ const startValidation = validateItems(startItems, "start phase");
391
+ const errors = [...startValidation.errors];
392
+ warnings.push(...startValidation.warnings);
393
+
394
+ // Refresh phase
395
+ let refreshItemCount: number | undefined;
396
+ if (sourceMethods?.refresh) {
397
+ try {
398
+ const emittedBefore = getEmitted().length;
399
+
400
+ await Promise.race([
401
+ sourceMethods.refresh(),
402
+ new Promise((_, reject) =>
403
+ setTimeout(
404
+ () => reject(new Error("Refresh phase timed out (30s)")),
405
+ 30000,
406
+ ),
407
+ ),
408
+ ]);
409
+
410
+ const allEmitted = getEmitted();
411
+ const refreshEmissions = allEmitted.slice(emittedBefore);
412
+ const refreshItems =
413
+ refreshEmissions.length > 0
414
+ ? refreshEmissions[refreshEmissions.length - 1]
415
+ : [];
416
+ refreshItemCount = refreshItems.length;
417
+
418
+ const refreshValidation = validateItems(refreshItems, "refresh phase");
419
+ errors.push(...refreshValidation.errors);
420
+ warnings.push(...refreshValidation.warnings);
421
+ } catch (err: any) {
422
+ errors.push(`Refresh phase error: ${err.message ?? err}`);
423
+ }
424
+ }
425
+
426
+ // Stop phase
427
+ if (sourceMethods?.stop) {
428
+ try {
429
+ await sourceMethods.stop();
430
+ } catch {
431
+ // ignore stop errors
432
+ }
433
+ }
434
+
435
+ const durationMs = Date.now() - start;
436
+
437
+ // ─── Output Results ──────────────────────────────────────────────────
438
+
439
+ const line = "\u2500".repeat(60);
440
+ const status = errors.length > 0 ? "fail" : "pass";
441
+
442
+ console.log(`\n${color.bold("Test Results")}`);
443
+ console.log(line);
444
+
445
+ const duration = color.dim(`(${durationMs}ms)`);
446
+
447
+ if (status === "pass") {
448
+ const counts =
449
+ refreshItemCount !== undefined
450
+ ? `${startItems.length} items, refresh: ${refreshItemCount}`
451
+ : `${startItems.length} items`;
452
+ console.log(` ${color.green("PASS")} ${counts} ${duration}`);
453
+ for (const w of warnings) {
454
+ console.log(` ${color.yellow("warning")}: ${w}`);
455
+ }
456
+ } else {
457
+ console.log(` ${color.red("FAIL")} ${duration}`);
458
+ for (const e of errors) {
459
+ console.log(` ${color.red("error")}: ${e}`);
460
+ }
461
+ for (const w of warnings) {
462
+ console.log(` ${color.yellow("warning")}: ${w}`);
463
+ }
464
+ }
465
+
466
+ console.log(line);
467
+
468
+ esbuild.stop();
469
+ process.exit(errors.length > 0 ? 1 : 0);
470
+ }
471
+
472
+ main().catch((err) => {
473
+ console.error(err);
474
+ process.exit(1);
475
+ });