@getjack/jack 0.1.4 → 0.1.6
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 +2 -6
- package/src/commands/agents.ts +9 -24
- package/src/commands/clone.ts +27 -0
- package/src/commands/down.ts +31 -57
- package/src/commands/feedback.ts +4 -5
- package/src/commands/link.ts +147 -0
- package/src/commands/login.ts +124 -1
- package/src/commands/logs.ts +8 -18
- package/src/commands/new.ts +7 -1
- package/src/commands/projects.ts +166 -105
- package/src/commands/secrets.ts +7 -6
- package/src/commands/services.ts +5 -4
- package/src/commands/tag.ts +282 -0
- package/src/commands/unlink.ts +30 -0
- package/src/index.ts +46 -1
- package/src/lib/auth/index.ts +2 -0
- package/src/lib/auth/store.ts +26 -2
- package/src/lib/binding-validator.ts +4 -13
- package/src/lib/build-helper.ts +93 -5
- package/src/lib/control-plane.ts +137 -0
- package/src/lib/deploy-mode.ts +1 -1
- package/src/lib/managed-deploy.ts +11 -1
- package/src/lib/managed-down.ts +7 -20
- package/src/lib/paths-index.test.ts +546 -0
- package/src/lib/paths-index.ts +310 -0
- package/src/lib/project-link.test.ts +459 -0
- package/src/lib/project-link.ts +279 -0
- package/src/lib/project-list.test.ts +581 -0
- package/src/lib/project-list.ts +449 -0
- package/src/lib/project-operations.ts +304 -183
- package/src/lib/project-resolver.ts +191 -211
- package/src/lib/tags.ts +389 -0
- package/src/lib/telemetry.ts +86 -157
- package/src/lib/zip-packager.ts +9 -0
- package/src/templates/index.ts +5 -3
- package/templates/api/.jack/template.json +4 -0
- package/templates/hello/.jack/template.json +4 -0
- package/templates/miniapp/.jack/template.json +4 -0
- package/templates/nextjs/.jack.json +28 -0
- package/templates/nextjs/app/globals.css +9 -0
- package/templates/nextjs/app/layout.tsx +19 -0
- package/templates/nextjs/app/page.tsx +8 -0
- package/templates/nextjs/bun.lock +2232 -0
- package/templates/nextjs/cloudflare-env.d.ts +3 -0
- package/templates/nextjs/next-env.d.ts +6 -0
- package/templates/nextjs/next.config.ts +8 -0
- package/templates/nextjs/open-next.config.ts +6 -0
- package/templates/nextjs/package.json +24 -0
- package/templates/nextjs/public/_headers +2 -0
- package/templates/nextjs/tsconfig.json +44 -0
- package/templates/nextjs/wrangler.jsonc +17 -0
- package/src/lib/local-paths.test.ts +0 -902
- package/src/lib/local-paths.ts +0 -258
- package/src/lib/registry.ts +0 -181
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project List - data layer and formatters for jack ls
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - ProjectListItem interface for display
|
|
6
|
+
* - Conversion from ResolvedProject
|
|
7
|
+
* - Sorting/filtering helpers
|
|
8
|
+
* - Output formatters for grouped and flat views
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import type { ResolvedProject } from "./project-resolver.ts";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Display-focused project representation
|
|
21
|
+
*/
|
|
22
|
+
export interface ProjectListItem {
|
|
23
|
+
name: string;
|
|
24
|
+
status: "live" | "error" | "local-only" | "syncing" | "auth-expired";
|
|
25
|
+
url: string | null;
|
|
26
|
+
localPath: string | null;
|
|
27
|
+
updatedAt: string | null;
|
|
28
|
+
isLocal: boolean;
|
|
29
|
+
isCloudOnly: boolean;
|
|
30
|
+
errorMessage?: string;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Projects grouped by display section
|
|
36
|
+
*/
|
|
37
|
+
export interface GroupedProjects {
|
|
38
|
+
errors: ProjectListItem[];
|
|
39
|
+
local: ProjectListItem[];
|
|
40
|
+
cloudOnly: ProjectListItem[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Colors
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
const isColorEnabled = !process.env.NO_COLOR && process.stderr.isTTY !== false;
|
|
48
|
+
|
|
49
|
+
export const colors = {
|
|
50
|
+
reset: isColorEnabled ? "\x1b[0m" : "",
|
|
51
|
+
dim: isColorEnabled ? "\x1b[90m" : "",
|
|
52
|
+
green: isColorEnabled ? "\x1b[32m" : "",
|
|
53
|
+
yellow: isColorEnabled ? "\x1b[33m" : "",
|
|
54
|
+
red: isColorEnabled ? "\x1b[31m" : "",
|
|
55
|
+
cyan: isColorEnabled ? "\x1b[36m" : "",
|
|
56
|
+
bold: isColorEnabled ? "\x1b[1m" : "",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Neon tag colors - colorblind-safe, work on light/dark themes
|
|
60
|
+
const TAG_COLORS = isColorEnabled
|
|
61
|
+
? [
|
|
62
|
+
"\x1b[96m", // bright cyan
|
|
63
|
+
"\x1b[95m", // bright magenta
|
|
64
|
+
"\x1b[94m", // bright blue
|
|
65
|
+
"\x1b[92m", // bright green
|
|
66
|
+
"\x1b[93m", // bright yellow
|
|
67
|
+
"\x1b[97m", // bright white
|
|
68
|
+
]
|
|
69
|
+
: [];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Hash a tag name to a consistent color index
|
|
73
|
+
*/
|
|
74
|
+
function hashTag(tag: string): number {
|
|
75
|
+
let hash = 0;
|
|
76
|
+
for (const char of tag) {
|
|
77
|
+
hash = (hash << 5) - hash + char.charCodeAt(0);
|
|
78
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
79
|
+
}
|
|
80
|
+
return Math.abs(hash) % TAG_COLORS.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a color map for all unique tags across projects
|
|
85
|
+
* Ensures same tag always gets same color
|
|
86
|
+
*/
|
|
87
|
+
export function buildTagColorMap(items: ProjectListItem[]): Map<string, string> {
|
|
88
|
+
const colorMap = new Map<string, string>();
|
|
89
|
+
if (!isColorEnabled || TAG_COLORS.length === 0) return colorMap;
|
|
90
|
+
|
|
91
|
+
// Collect all unique tags
|
|
92
|
+
const allTags = new Set<string>();
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
for (const tag of item.tags ?? []) {
|
|
95
|
+
allTags.add(tag);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Assign consistent colors based on hash
|
|
100
|
+
for (const tag of allTags) {
|
|
101
|
+
const colorIndex = hashTag(tag);
|
|
102
|
+
colorMap.set(tag, TAG_COLORS[colorIndex] || "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return colorMap;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// Status Icons
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
export const STATUS_ICONS: Record<ProjectListItem["status"], string> = {
|
|
113
|
+
live: "\u25CF", // ●
|
|
114
|
+
error: "\u2716", // ✖
|
|
115
|
+
"local-only": "\u25CC", // ◌
|
|
116
|
+
syncing: "\u25D0", // ◐
|
|
117
|
+
"auth-expired": "\u26A0", // ⚠
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Data Transformation
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convert ResolvedProject[] to ProjectListItem[]
|
|
126
|
+
*/
|
|
127
|
+
export function toListItems(projects: ResolvedProject[]): ProjectListItem[] {
|
|
128
|
+
return projects.map((proj) => ({
|
|
129
|
+
name: proj.name,
|
|
130
|
+
status: proj.status as ProjectListItem["status"],
|
|
131
|
+
url: proj.url || null,
|
|
132
|
+
localPath: proj.localPath || null,
|
|
133
|
+
updatedAt: proj.updatedAt || null,
|
|
134
|
+
isLocal: !!proj.localPath && proj.sources.filesystem,
|
|
135
|
+
isCloudOnly: !proj.localPath && proj.sources.controlPlane,
|
|
136
|
+
errorMessage: proj.errorMessage,
|
|
137
|
+
tags: proj.tags,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Sorting & Filtering
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sort by updatedAt descending (most recent first)
|
|
147
|
+
* Items without updatedAt are sorted to the end
|
|
148
|
+
*/
|
|
149
|
+
export function sortByUpdated(items: ProjectListItem[]): ProjectListItem[] {
|
|
150
|
+
return [...items].sort((a, b) => {
|
|
151
|
+
// Items without dates go to the end
|
|
152
|
+
if (!a.updatedAt && !b.updatedAt) return a.name.localeCompare(b.name);
|
|
153
|
+
if (!a.updatedAt) return 1;
|
|
154
|
+
if (!b.updatedAt) return -1;
|
|
155
|
+
|
|
156
|
+
// Most recent first
|
|
157
|
+
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Group projects into sections for display
|
|
163
|
+
*/
|
|
164
|
+
export function groupProjects(items: ProjectListItem[]): GroupedProjects {
|
|
165
|
+
const errors: ProjectListItem[] = [];
|
|
166
|
+
const local: ProjectListItem[] = [];
|
|
167
|
+
const cloudOnly: ProjectListItem[] = [];
|
|
168
|
+
|
|
169
|
+
for (const item of items) {
|
|
170
|
+
if (item.status === "error" || item.status === "auth-expired") {
|
|
171
|
+
errors.push(item);
|
|
172
|
+
} else if (item.isLocal) {
|
|
173
|
+
local.push(item);
|
|
174
|
+
} else if (item.isCloudOnly) {
|
|
175
|
+
cloudOnly.push(item);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { errors, local, cloudOnly };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Filter items by status
|
|
184
|
+
*/
|
|
185
|
+
export function filterByStatus(items: ProjectListItem[], status: string): ProjectListItem[] {
|
|
186
|
+
// Handle "local" as an alias for "local-only"
|
|
187
|
+
const normalizedStatus = status === "local" ? "local-only" : status;
|
|
188
|
+
return items.filter((item) => item.status === normalizedStatus);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Filter items by tag
|
|
193
|
+
* When multiple tags provided, uses AND logic (project must have ALL tags)
|
|
194
|
+
*/
|
|
195
|
+
export function filterByTag(items: ProjectListItem[], tags: string[]): ProjectListItem[] {
|
|
196
|
+
if (tags.length === 0) return items;
|
|
197
|
+
|
|
198
|
+
return items.filter((item) => {
|
|
199
|
+
const projectTags = item.tags ?? [];
|
|
200
|
+
// AND logic: project must have ALL specified tags
|
|
201
|
+
return tags.every((tag) => projectTags.includes(tag));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Path Utilities
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Replace home directory with ~
|
|
211
|
+
*/
|
|
212
|
+
export function shortenPath(path: string): string {
|
|
213
|
+
const home = homedir();
|
|
214
|
+
if (path.startsWith(home)) {
|
|
215
|
+
return `~${path.slice(home.length)}`;
|
|
216
|
+
}
|
|
217
|
+
return path;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Truncate long paths: ~/very/long/path/here -> ~/very/.../here
|
|
222
|
+
*/
|
|
223
|
+
export function truncatePath(path: string, maxLen: number): string {
|
|
224
|
+
if (path.length <= maxLen) return path;
|
|
225
|
+
|
|
226
|
+
// Keep first and last parts
|
|
227
|
+
const parts = path.split("/");
|
|
228
|
+
if (parts.length <= 3) {
|
|
229
|
+
// Too few parts to truncate meaningfully
|
|
230
|
+
return `${path.slice(0, maxLen - 3)}...`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Try to keep first and last part with ... in middle
|
|
234
|
+
const first = parts[0] || "";
|
|
235
|
+
const last = parts[parts.length - 1] || "";
|
|
236
|
+
|
|
237
|
+
// Check if we have room for first/...last
|
|
238
|
+
const truncated = `${first}/.../${last}`;
|
|
239
|
+
if (truncated.length <= maxLen) {
|
|
240
|
+
return truncated;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Fall back to simple truncation
|
|
244
|
+
return `${path.slice(0, maxLen - 3)}...`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ============================================================================
|
|
248
|
+
// Formatters
|
|
249
|
+
// ============================================================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Format tags for inline display after project name
|
|
253
|
+
* - Returns empty string if no tags
|
|
254
|
+
* - Shows up to 3 tags as #tag with neon colors
|
|
255
|
+
* - Truncates with +N if more: #api #prod +2
|
|
256
|
+
* - Each tag gets consistent color across all projects
|
|
257
|
+
*/
|
|
258
|
+
export function formatTagsInline(
|
|
259
|
+
tags: string[] | undefined,
|
|
260
|
+
colorMap?: Map<string, string>,
|
|
261
|
+
): string {
|
|
262
|
+
if (!tags || tags.length === 0) return "";
|
|
263
|
+
|
|
264
|
+
const maxTags = 3;
|
|
265
|
+
const tagsToShow = tags.length <= maxTags ? tags : tags.slice(0, 2);
|
|
266
|
+
|
|
267
|
+
const formatted = tagsToShow.map((tag) => {
|
|
268
|
+
const tagColor = colorMap?.get(tag) || colors.cyan;
|
|
269
|
+
return `${tagColor}#${tag}${colors.reset}`;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (tags.length > maxTags) {
|
|
273
|
+
const remaining = tags.length - 2;
|
|
274
|
+
formatted.push(`${colors.dim}+${remaining}${colors.reset}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return formatted.join(" ");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface FormatLineOptions {
|
|
281
|
+
indent?: number;
|
|
282
|
+
showUrl?: boolean;
|
|
283
|
+
tagColorMap?: Map<string, string>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Format a single project line
|
|
288
|
+
*/
|
|
289
|
+
export function formatProjectLine(item: ProjectListItem, options: FormatLineOptions = {}): string {
|
|
290
|
+
const { indent = 4, showUrl = true, tagColorMap } = options;
|
|
291
|
+
const padding = " ".repeat(indent);
|
|
292
|
+
|
|
293
|
+
const icon = STATUS_ICONS[item.status];
|
|
294
|
+
const statusColor =
|
|
295
|
+
item.status === "error" || item.status === "auth-expired"
|
|
296
|
+
? colors.red
|
|
297
|
+
: item.status === "live"
|
|
298
|
+
? colors.green
|
|
299
|
+
: item.status === "syncing"
|
|
300
|
+
? colors.yellow
|
|
301
|
+
: colors.dim;
|
|
302
|
+
|
|
303
|
+
const name = item.name.slice(0, 20).padEnd(20);
|
|
304
|
+
const tags = formatTagsInline(item.tags, tagColorMap);
|
|
305
|
+
const status = item.status.padEnd(12);
|
|
306
|
+
|
|
307
|
+
let url = "";
|
|
308
|
+
if (showUrl && item.url) {
|
|
309
|
+
url = item.url.replace("https://", "");
|
|
310
|
+
} else if (
|
|
311
|
+
showUrl &&
|
|
312
|
+
(item.status === "error" || item.status === "auth-expired") &&
|
|
313
|
+
item.errorMessage
|
|
314
|
+
) {
|
|
315
|
+
url = `${colors.dim}${item.errorMessage}${colors.reset}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return `${padding}${statusColor}${icon}${colors.reset} ${name}${tags ? ` ${tags}` : ""} ${statusColor}${status}${colors.reset} ${url}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface FormatErrorSectionOptions {
|
|
322
|
+
tagColorMap?: Map<string, string>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Format the "Needs attention" (errors) section
|
|
327
|
+
*/
|
|
328
|
+
export function formatErrorSection(
|
|
329
|
+
items: ProjectListItem[],
|
|
330
|
+
options: FormatErrorSectionOptions = {},
|
|
331
|
+
): string {
|
|
332
|
+
if (items.length === 0) return "";
|
|
333
|
+
const { tagColorMap } = options;
|
|
334
|
+
|
|
335
|
+
const lines: string[] = [];
|
|
336
|
+
lines.push(
|
|
337
|
+
` ${colors.red}${STATUS_ICONS.error} Needs attention (${items.length})${colors.reset}`,
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
for (const item of items) {
|
|
341
|
+
lines.push(formatProjectLine(item, { indent: 4, tagColorMap }));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return lines.join("\n");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface FormatLocalSectionOptions {
|
|
348
|
+
tagColorMap?: Map<string, string>;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Format the "Local" section, grouped by parent directory
|
|
353
|
+
*/
|
|
354
|
+
export function formatLocalSection(
|
|
355
|
+
items: ProjectListItem[],
|
|
356
|
+
options: FormatLocalSectionOptions = {},
|
|
357
|
+
): string {
|
|
358
|
+
if (items.length === 0) return "";
|
|
359
|
+
const { tagColorMap } = options;
|
|
360
|
+
|
|
361
|
+
// Group by parent directory
|
|
362
|
+
interface DirGroup {
|
|
363
|
+
displayPath: string;
|
|
364
|
+
projects: ProjectListItem[];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const groups = new Map<string, DirGroup>();
|
|
368
|
+
|
|
369
|
+
for (const item of items) {
|
|
370
|
+
if (!item.localPath) continue;
|
|
371
|
+
const parent = dirname(item.localPath);
|
|
372
|
+
if (!groups.has(parent)) {
|
|
373
|
+
groups.set(parent, {
|
|
374
|
+
displayPath: shortenPath(parent),
|
|
375
|
+
projects: [],
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
groups.get(parent)?.projects.push(item);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const lines: string[] = [];
|
|
382
|
+
|
|
383
|
+
for (const [_parentPath, group] of groups) {
|
|
384
|
+
lines.push(` ${colors.dim}${group.displayPath}/${colors.reset}`);
|
|
385
|
+
|
|
386
|
+
const sortedProjects = group.projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
387
|
+
|
|
388
|
+
for (let i = 0; i < sortedProjects.length; i++) {
|
|
389
|
+
const proj = sortedProjects[i];
|
|
390
|
+
if (!proj) continue;
|
|
391
|
+
const isLast = i === sortedProjects.length - 1;
|
|
392
|
+
const prefix = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; // └── or ├──
|
|
393
|
+
|
|
394
|
+
const icon = STATUS_ICONS[proj.status];
|
|
395
|
+
const statusColor =
|
|
396
|
+
proj.status === "error" || proj.status === "auth-expired"
|
|
397
|
+
? colors.red
|
|
398
|
+
: proj.status === "live"
|
|
399
|
+
? colors.green
|
|
400
|
+
: proj.status === "syncing"
|
|
401
|
+
? colors.yellow
|
|
402
|
+
: colors.dim;
|
|
403
|
+
|
|
404
|
+
const url = proj.url ? proj.url.replace("https://", "") : "";
|
|
405
|
+
const tags = formatTagsInline(proj.tags, tagColorMap);
|
|
406
|
+
|
|
407
|
+
lines.push(
|
|
408
|
+
` ${colors.dim}${prefix}${colors.reset} ${proj.name}${tags ? ` ${tags}` : ""} ${statusColor}${proj.status}${colors.reset}${url ? ` ${url}` : ""}`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return lines.join("\n");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface FormatCloudSectionOptions {
|
|
417
|
+
limit: number;
|
|
418
|
+
total: number;
|
|
419
|
+
tagColorMap?: Map<string, string>;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Format the "Cloud" section
|
|
424
|
+
*/
|
|
425
|
+
export function formatCloudSection(
|
|
426
|
+
items: ProjectListItem[],
|
|
427
|
+
options: FormatCloudSectionOptions,
|
|
428
|
+
): string {
|
|
429
|
+
if (items.length === 0) return "";
|
|
430
|
+
|
|
431
|
+
const { limit, total, tagColorMap } = options;
|
|
432
|
+
const showing = items.slice(0, limit);
|
|
433
|
+
const remaining = total - showing.length;
|
|
434
|
+
|
|
435
|
+
const lines: string[] = [];
|
|
436
|
+
lines.push(
|
|
437
|
+
` ${colors.green}${STATUS_ICONS.live} Cloud (showing ${showing.length} of ${total})${colors.reset}`,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
for (const item of showing) {
|
|
441
|
+
lines.push(formatProjectLine(item, { indent: 4, tagColorMap }));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (remaining > 0) {
|
|
445
|
+
lines.push(` ${colors.dim}... ${remaining} more${colors.reset}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return lines.join("\n");
|
|
449
|
+
}
|