@yadurajfleetos/cli 0.9.2 → 0.10.1

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/progress.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * a persistently unreachable control plane as an error.
15
15
  */
16
16
  import { request, CliError, EXIT } from './api.js';
17
+ import { bar } from './ui.js';
17
18
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
19
  /** A failure reason can be a build log tail; a one-line error gets one line of it. */
19
20
  export const firstLine = (text) => text.split('\n')[0].trim().slice(0, 200);
@@ -30,7 +31,51 @@ export function progressLine(p) {
30
31
  return undefined;
31
32
  const counter = p.step && p.ofSteps ? `${p.step}/${p.ofSteps} ` : '';
32
33
  const platform = p.platform ? `${p.platform.replace(/^linux\//, '')} ` : '';
33
- return `${counter}${platform}${p.detail}`;
34
+ // Emulation is the answer to "why is this taking so long", and a build that
35
+ // takes three minutes instead of twenty seconds is almost always this. Said
36
+ // once, on the line already being drawn, rather than as a separate warning.
37
+ const how = p.emulated ? ' (emulated — slow)' : '';
38
+ return `${counter}${platform}${p.detail}${how}`;
39
+ }
40
+ /**
41
+ * A duration a person reads, not a step suffix.
42
+ *
43
+ * ui.ts has `duration`, which is dim, colour-wrapped, prefixed with a space
44
+ * and renders three minutes as "180s" — right for the end of a finished step,
45
+ * wrong for a sentence about how long is left.
46
+ */
47
+ function human(ms) {
48
+ const total = Math.max(0, Math.round(ms / 1000));
49
+ if (total < 60)
50
+ return `${total}s`;
51
+ const minutes = Math.floor(total / 60);
52
+ const seconds = total % 60;
53
+ if (minutes < 60)
54
+ return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
55
+ const hours = Math.floor(minutes / 60);
56
+ return `${hours}h ${minutes % 60}m`;
57
+ }
58
+ /**
59
+ * Elapsed against what this service usually takes.
60
+ *
61
+ * Only from real history: on a first deploy there is nothing honest to say,
62
+ * and a bar filled from a number nobody measured is the same lie as the "not
63
+ * needed" it replaces. Overrunning is shown as overrunning rather than parked
64
+ * at the end of the bar — a deploy that is genuinely slow today is exactly
65
+ * when somebody needs to know.
66
+ */
67
+ export function etaLine(p, now = Date.now()) {
68
+ if (!p.typicalMs || !p.since)
69
+ return undefined;
70
+ const elapsed = now - new Date(p.since).getTime();
71
+ if (elapsed < 0)
72
+ return undefined;
73
+ const fraction = Math.min(1, elapsed / p.typicalMs);
74
+ const left = p.typicalMs - elapsed;
75
+ const tail = left > 0
76
+ ? `~${human(left)} left`
77
+ : `${human(-left)} over the usual ${human(p.typicalMs)}`;
78
+ return `${bar(fraction)} ${human(elapsed)} · ${tail}`;
34
79
  }
35
80
  /**
36
81
  * Poll `/progress` in the background while something else is being awaited.
@@ -184,7 +229,14 @@ export function phaseWalker(l, steps = DEPLOY_STEPS) {
184
229
  // poll, and it belongs on the step that decided it.
185
230
  advance(target, at === 0 ? (p.nodeName ?? undefined) : undefined);
186
231
  }
187
- const line = progressLine(p);
232
+ // The build line, or the estimate when there is no build line to show.
233
+ //
234
+ // Both on one row rather than two: the ladder redraws a fixed region and
235
+ // a row that appears and disappears makes the whole block jump. During a
236
+ // build the sub-step is the more useful of the two — it is proof of
237
+ // movement — and the estimate carries the rest of the wait, when the
238
+ // node is pulling an image and nothing is being logged at all.
239
+ const line = progressLine(p) ?? etaLine(p);
188
240
  if (line && at < steps.length)
189
241
  l.detail(steps[at].key, line);
190
242
  },
package/dist/repomap.js CHANGED
@@ -27,20 +27,34 @@ const SKIP = new Set([
27
27
  * the middle of a server file is business logic nobody needs to see.
28
28
  */
29
29
  const EVIDENCE = [
30
- { name: /^package\.json$/, lines: 60 },
31
- { name: /^(requirements|requirements-prod)\.txt$/, lines: 40 },
32
- { name: /^(pyproject\.toml|Pipfile|go\.mod|Cargo\.toml|Gemfile)$/, lines: 40 },
33
- { name: /^Dockerfile(\..+)?$/, lines: 40 },
34
- { name: /^(docker-)?compose\.ya?ml$/, lines: 60 },
35
- { name: /^\.env\.(example|sample|template)$/, lines: 40 },
36
- { name: /^(main|server|app|index)\.(js|ts|mjs|py|go|rb)$/, lines: 40 },
37
- { name: /^(vite|next|nuxt|astro|svelte)\.config\.(js|ts|mjs)$/, lines: 25 },
38
- { name: /^README(\.md)?$/, lines: 20 },
30
+ // Tier 1 answers "what is this and what port does it serve" — the two
31
+ // questions the manifest is actually made of.
32
+ { name: /^package\.json$/, lines: 45, tier: 1 },
33
+ { name: /^(requirements|requirements-prod)\.txt$/, lines: 30, tier: 1 },
34
+ { name: /^(pyproject\.toml|Pipfile|go\.mod|Cargo\.toml|Gemfile)$/, lines: 30, tier: 1 },
35
+ { name: /^Dockerfile(\..+)?$/, lines: 30, tier: 1 },
36
+ { name: /^(docker-)?compose\.ya?ml$/, lines: 50, tier: 1 },
37
+ // Tier 2 is where a route prefix or a listen() hides.
38
+ { name: /^(main|server|app|index)\.(js|ts|mjs|py|go|rb)$/, lines: 30, tier: 2 },
39
+ { name: /^(vite|next|nuxt|astro|svelte)\.config\.(js|ts|mjs)$/, lines: 20, tier: 2 },
40
+ { name: /^\.env\.(example|sample|template)$/, lines: 25, tier: 2 },
41
+ // Tier 3 is context, and the first thing to go.
42
+ { name: /^README(\.md)?$/, lines: 15, tier: 3 },
39
43
  ];
40
44
  const MAX_DEPTH = 3;
41
- const MAX_TREE_ENTRIES = 300;
42
- /** Comfortably inside the endpoint's limit, with room for the draft. */
43
- const MAX_TOTAL_CHARS = 48_000;
45
+ /**
46
+ * Sized for a model's context, not for the endpoint's limit.
47
+ *
48
+ * A free Groq tier allows 8000 tokens a minute for the whole request. This
49
+ * repository's map came to 25kB — about 6,300 tokens — and with the system
50
+ * prompt and the draft on top the request was 8,901 and refused. Roughly four
51
+ * characters to the token, so 14kB of evidence leaves room for the prompt, the
52
+ * draft, and a reply containing a whole manifest.
53
+ */
54
+ const MAX_TOTAL_CHARS = 14_000;
55
+ /** The tree is orientation, not evidence, and it was a fifth of the budget. */
56
+ const MAX_TREE_ENTRIES = 120;
57
+ const MAX_TREE_CHARS = 2_500;
44
58
  function windowOf(text, lines) {
45
59
  const all = text.split('\n');
46
60
  if (all.length <= lines)
@@ -86,35 +100,66 @@ async function tree(root) {
86
100
  /** Build the evidence bundle for a repository root. */
87
101
  export async function repoMap(root = process.cwd()) {
88
102
  const paths = await tree(root);
89
- const sections = [
90
- '## Tree',
91
- paths.join('\n'),
92
- ];
103
+ let treeText = paths.join('\n');
104
+ if (treeText.length > MAX_TREE_CHARS) {
105
+ treeText = treeText.slice(0, MAX_TREE_CHARS).split('\n').slice(0, -1).join('\n') + '\n…';
106
+ }
107
+ const sections = ['## Tree', treeText];
93
108
  let budget = MAX_TOTAL_CHARS - sections.join('\n').length;
94
- for (const rel of paths) {
95
- if (rel.endsWith('/'))
96
- continue;
109
+ // Candidates, tagged with which service they belong to.
110
+ //
111
+ // The directory is the unit that matters: a monorepo is several services,
112
+ // and one of them having a README is worth less than another having its
113
+ // Dockerfile. Reading files in path order spent the whole budget inside the
114
+ // first two directories and left the rest of the repository undescribed.
115
+ const candidates = paths
116
+ .filter((rel) => !rel.endsWith('/'))
117
+ .map((rel) => {
97
118
  const base = rel.split('/').pop() ?? rel;
98
119
  const rule = EVIDENCE.find((e) => e.name.test(base));
99
- if (!rule)
100
- continue;
101
- const full = join(root, rel);
120
+ return rule ? { rel, rule, dir: rel.includes('/') ? rel.split('/')[0] : '.' } : null;
121
+ })
122
+ .filter((x) => x !== null);
123
+ const read = async (c) => {
102
124
  try {
125
+ const full = join(root, c.rel);
103
126
  const info = await stat(full);
104
127
  // A megabyte of lockfile-shaped JSON is not evidence.
105
128
  if (info.size > 512 * 1024)
106
- continue;
129
+ return null;
107
130
  const text = await readFile(full, 'utf8');
108
- const block = `\n## ${rel}\n${windowOf(text, rule.lines)}`;
109
- // Stop cleanly at the budget rather than sending a truncated file that
110
- // reads as though the repository itself is malformed.
111
- if (block.length > budget)
112
- break;
113
- sections.push(block);
114
- budget -= block.length;
131
+ return `\n## ${c.rel}\n${windowOf(text, c.rule.lines)}`;
115
132
  }
116
133
  catch {
117
134
  // Unreadable is not fatal; it is simply not evidence.
135
+ return null;
136
+ }
137
+ };
138
+ // Tier by tier, and within a tier one file per directory before any
139
+ // directory gets a second. Every service is described before any service is
140
+ // described twice, so trimming costs depth rather than whole services.
141
+ for (const tier of [1, 2, 3]) {
142
+ const inTier = candidates.filter((c) => c.rule.tier === tier);
143
+ const byDir = new Map();
144
+ for (const c of inTier) {
145
+ byDir.set(c.dir, [...(byDir.get(c.dir) ?? []), c]);
146
+ }
147
+ let round = 0;
148
+ let placed = true;
149
+ while (placed && budget > 0) {
150
+ placed = false;
151
+ for (const list of byDir.values()) {
152
+ const c = list[round];
153
+ if (!c)
154
+ continue;
155
+ placed = true;
156
+ const block = await read(c);
157
+ if (!block || block.length > budget)
158
+ continue;
159
+ sections.push(block);
160
+ budget -= block.length;
161
+ }
162
+ round++;
118
163
  }
119
164
  }
120
165
  return sections.join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",