gipity 1.0.409 → 1.0.410

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.
@@ -24,7 +24,7 @@ export function claudeInstallPlan(platformOverride) {
24
24
  /** Whether the `claude` binary resolves on PATH. */
25
25
  export function isClaudeInstalled(platformOverride) {
26
26
  try {
27
- execSync(claudeInstallPlan(platformOverride).checkCmd, { stdio: 'ignore' });
27
+ execSync(claudeInstallPlan(platformOverride).checkCmd, { stdio: 'ignore', windowsHide: true });
28
28
  return true;
29
29
  }
30
30
  catch {
@@ -59,6 +59,7 @@ export function probeClaudeAuthenticated() {
59
59
  timeout: 60_000,
60
60
  stdio: ['ignore', 'pipe', 'ignore'],
61
61
  encoding: 'utf-8',
62
+ windowsHide: true,
62
63
  });
63
64
  return typeof out === 'string' && out.trim().length > 0;
64
65
  }
@@ -77,7 +78,7 @@ export function ensureClaudeInstalled(opts = {}) {
77
78
  return { installed: true, alreadyPresent: true };
78
79
  const { installArgv } = claudeInstallPlan();
79
80
  try {
80
- execSync(installArgv.join(' '), { stdio: opts.quiet ? 'ignore' : 'inherit' });
81
+ execSync(installArgv.join(' '), { stdio: opts.quiet ? 'ignore' : 'inherit', windowsHide: true });
81
82
  }
82
83
  catch {
83
84
  // Fall through to a definitive PATH re-check rather than trusting the throw.
@@ -5,7 +5,7 @@ import { Command } from 'commander';
5
5
  import { post } from '../api.js';
6
6
  import { requireConfig } from '../config.js';
7
7
  import { sync } from '../sync.js';
8
- import { success, muted, bold } from '../colors.js';
8
+ import { success, muted, bold, warning } from '../colors.js';
9
9
  import { run } from '../helpers/index.js';
10
10
  import { createProgressReporter, withSpinner } from '../progress.js';
11
11
  const STARTERS = [
@@ -225,6 +225,15 @@ export const addCommand = new Command('add')
225
225
  for (const n of data.notes)
226
226
  console.log(n);
227
227
  }
228
+ // Partial install (server HTTP 207): some files were dropped. Surface them so
229
+ // the user knows the install is incomplete rather than reading a clean success.
230
+ if (data.failed?.length) {
231
+ console.log('');
232
+ console.warn(warning(`${data.failed.length} file(s) failed to install:`));
233
+ for (const f of data.failed)
234
+ console.warn(warning(` ! ${f.path}: ${f.error}`));
235
+ console.warn(warning(`Re-run \`gipity add ${data.kit ?? name}${opts.force ? ' --force' : ''}\` to retry the dropped files.`));
236
+ }
228
237
  // After scaffolding an app, point at kits as the next step - they add
229
238
  // features (multiplayer, etc.) into the app you just created.
230
239
  if (data.kind !== 'kit' && KITS.length > 0) {
@@ -0,0 +1,69 @@
1
+ import { Command } from 'commander';
2
+ import { get, post } from '../api.js';
3
+ import { resolveProjectContext } from '../config.js';
4
+ import { bold, muted, success, warning } from '../colors.js';
5
+ import { run } from '../helpers/index.js';
6
+ // Kept in sync with @easyclaw/shared BUG_REPORT_CATEGORIES / _SEVERITIES (the CLI
7
+ // package can't import the server's shared package). The server validates
8
+ // authoritatively; these are for --help and a friendly client-side check.
9
+ const CATEGORIES = ['cli', 'deploy', 'template', 'kit', 'db', 'docs', 'skill', 'service', 'sandbox', 'other'];
10
+ const SEVERITIES = ['S1', 'S2', 'S3', 'S4'];
11
+ const SEVERITY_HINT = 'S1=blocker, S2=major, S3=minor, S4=friction';
12
+ export const bugCommand = new Command('bug')
13
+ .description('Report Gipity platform bugs / friction in real time')
14
+ .addHelpText('after', `\nCapture platform friction the moment you hit it — even one you worked around —` +
15
+ `\nso the team can triage it into a fix.\n` +
16
+ `\nCategories: ${CATEGORIES.join(', ')}` +
17
+ `\nSeverity: ${SEVERITY_HINT}` +
18
+ `\nNever include PII or user data — describe the platform problem in the abstract.`);
19
+ bugCommand
20
+ .command('report')
21
+ .description('File a bug / friction report about the Gipity platform')
22
+ .requiredOption('--category <cat>', `One of: ${CATEGORIES.join(', ')}`)
23
+ .requiredOption('--severity <sev>', SEVERITY_HINT)
24
+ .requiredOption('--summary <text>', 'One line, 7 words max. No PII/user data.')
25
+ .option('--detail <text>', 'Succinct: what you did, what failed, the workaround. No PII/user data.')
26
+ .option('--project <guid-or-slug>', 'Attribute to a specific project instead of cwd / Home')
27
+ .option('--json', 'Output raw JSON')
28
+ .action((opts) => run('Bug report', async () => {
29
+ const category = String(opts.category).toLowerCase();
30
+ const severity = /^[1-4]$/.test(opts.severity) ? `S${opts.severity}` : String(opts.severity).toUpperCase();
31
+ if (!CATEGORIES.includes(category))
32
+ throw new Error(`--category must be one of: ${CATEGORIES.join(', ')}`);
33
+ if (!SEVERITIES.includes(severity))
34
+ throw new Error(`--severity must be one of: ${SEVERITY_HINT}`);
35
+ if (String(opts.summary).trim().split(/\s+/).filter(Boolean).length > 7) {
36
+ throw new Error('--summary must be 7 words or fewer');
37
+ }
38
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
39
+ const res = await post(`/api/${config.projectGuid}/services/bug-report/submit`, { category, severity, summary: opts.summary, detail: opts.detail });
40
+ if (opts.json) {
41
+ console.log(JSON.stringify(res.data));
42
+ return;
43
+ }
44
+ console.log(success(`✓ Bug report filed (${res.data.report_guid}) — queued for triage.`));
45
+ }));
46
+ bugCommand
47
+ .command('list')
48
+ .description('List the bug / friction reports you have filed')
49
+ .option('--project <guid-or-slug>', 'Resolve auth against a specific project instead of cwd / Home')
50
+ .option('--json', 'Output raw JSON')
51
+ .action((opts) => run('Bug report', async () => {
52
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
53
+ const res = await get(`/api/${config.projectGuid}/services/bug-report/list`);
54
+ if (opts.json) {
55
+ console.log(JSON.stringify(res.data));
56
+ return;
57
+ }
58
+ const rows = res.data;
59
+ if (!rows.length) {
60
+ console.log(warning('No bug reports filed yet.'));
61
+ return;
62
+ }
63
+ console.log(bold(`${rows.length} report${rows.length === 1 ? '' : 's'}`));
64
+ for (const r of rows) {
65
+ console.log(` ${r.severity} ${muted(r.category.padEnd(8))} ${r.summary} ${muted(`[${r.status}]`)}`);
66
+ console.log(muted(` ${r.report_guid} ${r.created_at}`));
67
+ }
68
+ }));
69
+ //# sourceMappingURL=bug.js.map
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import { spawn } from 'node:child_process';
3
- import { get } from '../api.js';
4
- import { brand, muted } from '../colors.js';
2
+ import { spawnCommand } from '../platform.js';
3
+ import { get, post, ApiError } from '../api.js';
4
+ import { brand, dim, bold, muted, success } from '../colors.js';
5
5
  import { run, printList } from '../helpers/index.js';
6
6
  const PRICING_URL = 'https://prompt.gipity.ai/pricing';
7
7
  function openInBrowser(url) {
@@ -12,7 +12,7 @@ function openInBrowser(url) {
12
12
  process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] :
13
13
  ['xdg-open', [url]];
14
14
  try {
15
- const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
15
+ const child = spawnCommand(cmd, args, { stdio: 'ignore', detached: true });
16
16
  // A missing/non-executable launcher (ENOENT, or EACCES on WSL/minimal Linux
17
17
  // where xdg-open isn't runnable) is reported ASYNCHRONOUSLY via an 'error'
18
18
  // event - not a throw - so the try/catch alone can't stop it. With no
@@ -25,18 +25,92 @@ function openInBrowser(url) {
25
25
  // Synchronous spawn failure - caller prints the URL.
26
26
  }
27
27
  }
28
+ // ---- Plan limits formatting (folded in from the old `plan` command) ----
29
+ // Every key the plan definition enforces is rendered, so `credits` /
30
+ // `credits list` are a full-transparency view of what your plan actually caps.
31
+ const LIMIT_LABELS = {
32
+ maxProjects: 'Projects',
33
+ maxDatabases: 'Databases',
34
+ storageQuotaBytes: 'Storage',
35
+ maxWorkflows: 'Workflows',
36
+ minCronIntervalHours: 'Cron frequency',
37
+ maxConcurrentChats: 'Concurrent chats',
38
+ deployRatePerMinute: 'Deploys/min',
39
+ testFileConcurrency: 'Parallel tests',
40
+ };
41
+ // Nested serviceLimits (media generation entitlements). Convention:
42
+ // -1 = unlimited, 0 = Pro-only (blocked on free), N = N free uses/month.
43
+ const SERVICE_LABELS = {
44
+ video: 'Video generation',
45
+ music: 'Music generation',
46
+ image: 'Image generation',
47
+ audio: 'Speech & sound FX',
48
+ };
49
+ function formatLimit(key, value) {
50
+ if (typeof value !== 'number')
51
+ return String(value);
52
+ if (key === 'storageQuotaBytes') {
53
+ const gb = value / (1024 ** 3);
54
+ return `${gb.toFixed(gb >= 1 ? 1 : 2)} GB`;
55
+ }
56
+ if (key === 'minCronIntervalHours') {
57
+ return value === 0 ? 'no minimum' : `${value}h minimum`;
58
+ }
59
+ return value.toLocaleString();
60
+ }
61
+ function formatServiceLimit(value) {
62
+ if (value === -1)
63
+ return 'unlimited';
64
+ if (value === 0)
65
+ return 'Pro only';
66
+ if (typeof value === 'number')
67
+ return `${value.toLocaleString()}/mo free`;
68
+ return String(value);
69
+ }
70
+ function renderLimits(limits, indent = '') {
71
+ for (const key of Object.keys(LIMIT_LABELS)) {
72
+ const value = limits[key];
73
+ if (value !== undefined) {
74
+ console.log(`${indent}${LIMIT_LABELS[key].padEnd(18)} ${formatLimit(key, value)}`);
75
+ }
76
+ }
77
+ const services = limits.serviceLimits;
78
+ if (services && typeof services === 'object') {
79
+ console.log(`${indent}Media generation`);
80
+ for (const key of Object.keys(SERVICE_LABELS)) {
81
+ const value = services[key];
82
+ if (value !== undefined) {
83
+ console.log(`${indent} ${SERVICE_LABELS[key].padEnd(16)} ${formatServiceLimit(value)}`);
84
+ }
85
+ }
86
+ }
87
+ }
88
+ // `credits` is the single account/billing hub: tier, live balance, the full set
89
+ // of plan limits, and an upgrade nudge. `plan` aliases to it (so old muscle
90
+ // memory resolves to one implementation); compare plans with `plan list`.
28
91
  export const creditsCommand = new Command('credits')
29
- .description('View credits')
92
+ .alias('plan')
93
+ .description('Show your plan, credit balance, and limits')
30
94
  .option('--json', 'Output as JSON')
31
95
  .action((opts) => run('Credits', async () => {
96
+ // Version retention rides alongside the Storage limit; a failure fetching it
97
+ // must not sink the whole view, so degrade to null and omit the line.
98
+ const fetchRetention = () => get('/users/me/retention').then(r => r.data).catch(() => null);
32
99
  if (opts.json) {
33
- const res = await get('/credits/balance');
34
- console.log(JSON.stringify(res.data));
100
+ const [balRes, limitsRes, subRes, retention] = await Promise.all([
101
+ get('/credits/balance'),
102
+ get('/users/me/limits'),
103
+ get('/credits/subscription'),
104
+ fetchRetention(),
105
+ ]);
106
+ console.log(JSON.stringify({ subscription: subRes.data, balance: balRes.data, limits: limitsRes.data, retention }));
35
107
  return;
36
108
  }
37
- const [balRes, subRes] = await Promise.all([
109
+ const [balRes, subRes, limitsRes, retention] = await Promise.all([
38
110
  get('/credits/balance'),
39
111
  get('/credits/subscription'),
112
+ get('/users/me/limits'),
113
+ fetchRetention(),
40
114
  ]);
41
115
  const sub = subRes.data;
42
116
  const tierLabel = sub.tier === 'pro' ? 'Gipity Pro' : 'Free';
@@ -45,17 +119,170 @@ export const creditsCommand = new Command('credits')
45
119
  if (balRes.data.balances.length > 0) {
46
120
  for (const b of balRes.data.balances) {
47
121
  const exp = new Date(b.expiresAt).toLocaleDateString();
48
- console.log(`${b.source}: ${b.creditsRemaining.toLocaleString()} ${muted(`expires ${exp}`)}`);
122
+ console.log(` ${b.source}: ${b.creditsRemaining.toLocaleString()} ${muted(`expires ${exp}`)}`);
49
123
  }
50
124
  }
125
+ console.log('\nLimits:');
126
+ renderLimits(limitsRes.data.limits, ' ');
127
+ if (retention) {
128
+ console.log(` ${'Version retention'.padEnd(18)} ${retention.days} days / ${retention.count} copies`);
129
+ }
130
+ console.log('');
131
+ if (sub.tier !== 'pro') {
132
+ console.log(dim('Upgrade to Gipity Pro for higher limits and 20,000 credits/mo — run `gipity credits buy`.'));
133
+ }
134
+ else {
135
+ console.log(dim('Need more credits? Run `gipity credits list` to see credit packs, then `gipity credits buy <pack>`.'));
136
+ }
51
137
  }));
138
+ // `credits list` compares every plan (with full limits) and the available
139
+ // credit packs — the "what can I upgrade to" view.
52
140
  creditsCommand
53
- .command('buy')
54
- .description('Open the credits purchase page in your browser')
55
- .action(() => run('Buy credits', async () => {
56
- console.log(`Opening ${brand(PRICING_URL)} in your browser...`);
57
- openInBrowser(PRICING_URL);
58
- console.log(muted("If your browser didn't open automatically, copy the URL above."));
141
+ .command('list')
142
+ .description('Compare all plans and credit packs')
143
+ .option('--json', 'Output as JSON')
144
+ // optsWithGlobals(): the parent `credits` command also declares --json, which
145
+ // commander binds to the parent - read merged opts so --json is seen here.
146
+ .action((_opts, cmd) => run('Plans', async () => {
147
+ const opts = cmd.optsWithGlobals();
148
+ const [plansRes, productsRes, subRes] = await Promise.all([
149
+ get('/plans'),
150
+ get('/credits/products'),
151
+ get('/credits/subscription'),
152
+ ]);
153
+ if (opts.json) {
154
+ console.log(JSON.stringify({ currentTier: subRes.data.tier, plans: plansRes.data, products: productsRes.data }));
155
+ return;
156
+ }
157
+ const currentTier = subRes.data.tier;
158
+ const plans = plansRes.data;
159
+ if (plans.length === 0) {
160
+ console.log('No active plans.');
161
+ return;
162
+ }
163
+ console.log(bold('Plans'));
164
+ for (const plan of plans) {
165
+ const marker = plan.tier === currentTier ? brand('* ') : ' ';
166
+ const price = plan.monthlyPriceUsd > 0 ? ` - $${plan.monthlyPriceUsd}/mo` : ' - Free';
167
+ const credits = plan.monthlyCredits > 0
168
+ ? ` - ${plan.monthlyCredits.toLocaleString()} credits/mo (${plan.creditExpiryDays}-day expiry)`
169
+ : '';
170
+ console.log(`${marker}${plan.displayName} (${plan.tier})${price}${credits}`);
171
+ renderLimits(plan.limits, ' ');
172
+ console.log('');
173
+ }
174
+ const packs = productsRes.data.filter(p => p.type === 'one_time');
175
+ if (packs.length > 0) {
176
+ console.log(bold('Credit packs') + muted(' (require an active Pro subscription)'));
177
+ for (const pack of packs) {
178
+ const lock = pack.available ? '' : muted(' [Pro only]');
179
+ console.log(` ${pack.name} - $${pack.amountUsd} - ${pack.credits.toLocaleString()} credits${lock}`);
180
+ }
181
+ console.log('');
182
+ }
183
+ console.log(dim('* = your current plan. Upgrade or top up with `gipity credits buy [pro|<pack-credits>]`.'));
184
+ }));
185
+ // `credits buy` is the ONE purchase command. It resolves the target to a
186
+ // Stripe price and prints a checkout link (agent-friendly: the link is always
187
+ // printed so it can be relayed to the user; --open also launches a browser).
188
+ creditsCommand
189
+ .command('buy [target]')
190
+ .alias('upgrade')
191
+ .description('Upgrade your plan or buy a credit pack (target: a tier like "pro", or a pack credit amount)')
192
+ .option('--open', 'Open the checkout page in your browser')
193
+ .option('--json', 'Output the checkout URL as JSON')
194
+ // optsWithGlobals(): the parent `credits` command also declares --json, which
195
+ // commander binds to the parent - read merged opts so --json/--open are seen here.
196
+ .action((target, _opts, cmd) => run('Buy', async () => {
197
+ const opts = cmd.optsWithGlobals();
198
+ const [productsRes, subRes] = await Promise.all([
199
+ get('/credits/products'),
200
+ get('/credits/subscription'),
201
+ ]);
202
+ const products = productsRes.data;
203
+ const currentTier = subRes.data.tier;
204
+ // Resolve the target product. With no arg, default to the first available
205
+ // paid subscription (the upgrade path). With an arg, match a plan/pack by
206
+ // name or credit amount, mirroring the cloud agent's product keys.
207
+ // Non-happy exits must stay parseable under --json (agents rely on it), so
208
+ // emit a JSON { error } instead of prose when the flag is set.
209
+ const bail = (message, humanLines) => {
210
+ if (opts.json)
211
+ console.log(JSON.stringify({ error: message }));
212
+ else
213
+ for (const line of humanLines)
214
+ console.log(line);
215
+ };
216
+ let product;
217
+ if (!target) {
218
+ product = products.find(p => p.type === 'subscription' && p.available);
219
+ if (!product) {
220
+ bail(currentTier === 'pro'
221
+ ? "You're already on Gipity Pro. To top up credits, run `gipity credits list` and buy a pack, e.g. `gipity credits buy 20000`."
222
+ : 'No upgrade plan is available right now.', [
223
+ currentTier === 'pro'
224
+ ? `You're already on ${brand('Gipity Pro')}. To top up credits, run \`gipity credits list\` and buy a pack, e.g. \`gipity credits buy 20000\`.`
225
+ : 'No upgrade plan is available right now.',
226
+ dim(`Browse everything at ${PRICING_URL}`),
227
+ ]);
228
+ return;
229
+ }
230
+ }
231
+ else {
232
+ const key = target.toLowerCase();
233
+ product = products.find(p => p.type === 'subscription' && p.name.toLowerCase() === key)
234
+ // pack keyed by credit amount, e.g. "20000" - MUST be a pack, not a
235
+ // subscription (Pro's monthly_credits can equal a pack's credit count).
236
+ ?? products.find(p => p.type === 'one_time' && String(p.credits) === key)
237
+ // accept "pro" against the single paid plan when only one exists
238
+ ?? (key === 'pro' ? products.find(p => p.type === 'subscription') : undefined);
239
+ if (!product) {
240
+ bail(`Unknown option "${target}". Run \`gipity credits list\` to see plans and packs.`, [`Unknown option "${target}". Run \`gipity credits list\` to see plans and packs.`]);
241
+ return;
242
+ }
243
+ if (!product.available) {
244
+ bail(product.type === 'subscription'
245
+ ? `You're already on ${product.name}.`
246
+ : 'Credit packs require an active Pro subscription. Run `gipity credits buy pro` first.', [product.type === 'subscription'
247
+ ? `You're already on ${brand(product.name)}.`
248
+ : 'Credit packs require an active Pro subscription. Run `gipity credits buy pro` first.']);
249
+ return;
250
+ }
251
+ }
252
+ try {
253
+ const res = await post('/credits/purchase', { priceId: product.priceId });
254
+ const url = res.data.checkoutUrl;
255
+ if (opts.json) {
256
+ console.log(JSON.stringify({ product: product.name, amountUsd: product.amountUsd, checkoutUrl: url }));
257
+ return;
258
+ }
259
+ const verb = product.type === 'subscription' ? 'Upgrading to' : 'Buying';
260
+ const per = product.type === 'subscription' ? '/mo' : '';
261
+ console.log(`${verb} ${brand(product.name)} ${muted(`($${product.amountUsd}${per}, ${product.credits.toLocaleString()} credits)`)}`);
262
+ console.log('');
263
+ console.log(` ${bold('Checkout:')} ${success(url)}`);
264
+ console.log('');
265
+ console.log(dim('Open the link to complete your purchase — 2 minutes, cancel anytime. Your plan unlocks the moment payment clears.'));
266
+ if (opts.open)
267
+ openInBrowser(url);
268
+ }
269
+ catch (err) {
270
+ // Payments not configured / Stripe hiccup: fall back to the pricing page
271
+ // so the user is never dead-ended.
272
+ if (err instanceof ApiError) {
273
+ if (opts.json) {
274
+ console.log(JSON.stringify({ error: err.message, checkoutUrl: null, fallbackUrl: PRICING_URL }));
275
+ }
276
+ else {
277
+ console.log(muted(`Couldn't create a direct checkout link (${err.message}).`));
278
+ console.log(`Complete your purchase at ${brand(PRICING_URL)}`);
279
+ }
280
+ if (opts.open)
281
+ openInBrowser(PRICING_URL);
282
+ return;
283
+ }
284
+ throw err;
285
+ }
59
286
  }));
60
287
  creditsCommand
61
288
  .command('usage')
@@ -193,7 +193,7 @@ jobCommand
193
193
  .action(async (name, opts) => {
194
194
  const { existsSync, statSync } = await import('node:fs');
195
195
  const { resolve, join } = await import('node:path');
196
- const { spawn, spawnSync } = await import('node:child_process');
196
+ const { spawnCommand, spawnSyncCommand } = await import('../platform.js');
197
197
  const jobDir = resolve(process.cwd(), 'jobs', name);
198
198
  if (!existsSync(jobDir) || !statSync(jobDir).isDirectory()) {
199
199
  console.error(clrError(`jobs/${name}/ not found in current directory`));
@@ -213,7 +213,7 @@ jobCommand
213
213
  process.exit(1);
214
214
  }
215
215
  // Confirm docker is reachable before bothering to build args.
216
- const probe = spawnSync('docker', ['info'], { stdio: 'ignore' });
216
+ const probe = spawnSyncCommand('docker', ['info'], { stdio: 'ignore' });
217
217
  if (probe.status !== 0) {
218
218
  console.error(clrError('docker daemon not reachable. Start Docker Desktop / dockerd and retry.'));
219
219
  process.exit(1);
@@ -240,7 +240,7 @@ jobCommand
240
240
  shellCmd = `echo '[run-local] running ${handlerCmd} (${picked.runtime})' >&2 && exec ${handlerCmd}`;
241
241
  }
242
242
  const runArgs = ['run', ...sharedArgs, '--entrypoint', 'sh', opts.image, '-c', shellCmd];
243
- const child = spawn('docker', runArgs, { stdio: 'inherit' });
243
+ const child = spawnCommand('docker', runArgs, { stdio: 'inherit' });
244
244
  // A missing `docker` binary is reported asynchronously via 'error' (not a
245
245
  // throw), so a try/catch can't stop it - without this handler Node crashes
246
246
  // with a raw stack trace instead of a clear message.
@@ -11,9 +11,13 @@ export const pushCommand = new Command('push')
11
11
  try {
12
12
  const fullPath = resolve(file);
13
13
  if (opts.background) {
14
- // Fork to background - import child_process dynamically
15
- const { fork } = await import('child_process');
16
- const child = fork(process.argv[1], ['push', fullPath, '--quiet'], {
14
+ // Detach a background `gipity push` and exit immediately. Goes through
15
+ // spawnCommand (Node binary running our own entry script - no IPC
16
+ // channel is needed, so spawn beats fork), which defaults
17
+ // `windowsHide: true` so the detached child never flashes a console
18
+ // window on Windows.
19
+ const { spawnCommand } = await import('../platform.js');
20
+ const child = spawnCommand(process.execPath, [process.argv[1], 'push', fullPath, '--quiet'], {
17
21
  detached: true,
18
22
  stdio: 'ignore',
19
23
  });
@@ -14,7 +14,7 @@ import { bold, brand, dim, success, error as clrError, muted, } from '../colors.
14
14
  import * as state from '../relay/state.js';
15
15
  import * as daemon from '../relay/daemon.js';
16
16
  import { UnsupportedPlatformError } from '../relay/installers.js';
17
- import { pairDevice, startDaemon, installAutostart } from '../relay/setup.js';
17
+ import { pairDevice, startDaemon, installAutostart, removeAutostart } from '../relay/setup.js';
18
18
  import { registerInstallCommands } from './relay-install.js';
19
19
  export const relayCommand = new Command('relay')
20
20
  .description('Pair with the web CLI');
@@ -269,8 +269,32 @@ relayCommand
269
269
  console.error(muted('Local token cleared anyway. Visit the web CLI to confirm the server-side revoke.'));
270
270
  }
271
271
  state.clearDevice();
272
+ // Remove the OS autostart unit too. Otherwise the login service relaunches
273
+ // `relay run`, which - finding no device but a valid login - silently
274
+ // re-registers a NEW device, undoing this revoke (on macOS the old
275
+ // KeepAlive=true even relaunched on the clean exit). Best-effort: an
276
+ // unsupported OS or a missing unit is fine.
277
+ let autostartRemoved = false;
278
+ try {
279
+ autostartRemoved = removeAutostart().ok;
280
+ }
281
+ catch { /* unsupported platform / no unit - ignore */ }
282
+ // Stop the currently-running daemon now rather than waiting for it to
283
+ // notice via a 401 on its next poll (~up to a hold cycle).
284
+ const pidPath = state.getDaemonPidPath();
285
+ if (existsSync(pidPath)) {
286
+ const pid = parseInt(readFileSync(pidPath, 'utf-8').trim(), 10);
287
+ if (pid && !isNaN(pid)) {
288
+ try {
289
+ process.kill(pid, 'SIGTERM');
290
+ }
291
+ catch { /* already gone */ }
292
+ }
293
+ }
272
294
  console.log(success('Device revoked + local state cleared.'));
273
- console.log(muted('Any running background service will notice and exit within ~30s.'));
295
+ console.log(muted(autostartRemoved
296
+ ? 'Auto-start removed and the background service was signalled to stop.'
297
+ : 'The background service was signalled to stop.'));
274
298
  });
275
299
  // ─── gipity relay log ──────────────────────────────────────────────────
276
300
  relayCommand
@@ -23,10 +23,16 @@ export const removeCommand = new Command('remove')
23
23
  const res = opts.json
24
24
  ? await doRemove()
25
25
  : await withSpinner('Removing…', doRemove, { done: null });
26
- // Force the pull so the kit's deletions land locally without tripping the
27
- // bulk-deletion guard - the removal is an explicit, user-invoked action.
28
- const syncResult = await sync({ interactive: false, force: true, progress: opts.json ? undefined : createProgressReporter() });
29
26
  const data = res.data;
27
+ // Pull the kit's deletions locally. Whitelist ONLY the kit's own removed files
28
+ // so they bypass the bulk-delete guard (the removal is explicit), while any
29
+ // unrelated mass deletion pending on either side is still guarded - a blanket
30
+ // `force` here would let that ride in silently.
31
+ const syncResult = await sync({
32
+ interactive: false,
33
+ deleteWhitelist: data.removed ?? [],
34
+ progress: opts.json ? undefined : createProgressReporter(),
35
+ });
30
36
  if (opts.json) {
31
37
  console.log(JSON.stringify({ ...data, synced: syncResult.applied }));
32
38
  return;
@@ -0,0 +1,64 @@
1
+ import { Command } from 'commander';
2
+ import { get, patch } from '../api.js';
3
+ import { brand, muted } from '../colors.js';
4
+ import { run } from '../helpers/index.js';
5
+ /** Parse a CLI flag value as a positive whole number, or throw a friendly error.
6
+ * The server is the source of truth for the plan-cap bound; this just rejects
7
+ * the obviously-invalid input locally before spending a round trip. */
8
+ function parsePositiveInt(raw, flag) {
9
+ if (!/^\d+$/.test(raw.trim())) {
10
+ throw new Error(`${flag} must be a positive whole number.`);
11
+ }
12
+ const n = parseInt(raw, 10);
13
+ if (n < 1)
14
+ throw new Error(`${flag} must be a positive whole number.`);
15
+ return n;
16
+ }
17
+ function printRetention(d, updated) {
18
+ console.log(updated ? 'Version retention updated:' : 'Version retention:');
19
+ console.log(` ${brand(`${d.days} days`)} / ${brand(`${d.count} copies`)} ${muted('(whichever prunes first)')}`);
20
+ const daysNote = d.customDays ? 'custom' : 'plan default';
21
+ const countNote = d.customCount ? 'custom' : 'plan default';
22
+ console.log(` ${muted(`days: ${daysNote}, copies: ${countNote}`)}`);
23
+ console.log(muted(`Plan allows up to ${d.maxDays} days / ${d.maxCount} copies.`));
24
+ }
25
+ export const storageCommand = new Command('storage')
26
+ .description('Manage file storage settings');
27
+ storageCommand
28
+ .command('retention')
29
+ .description('View or adjust your file version-retention policy')
30
+ .option('--days <n>', 'Keep versions for at most N days (1..plan cap)')
31
+ .option('--count <n>', 'Keep at most N copies per file (1..plan cap)')
32
+ .option('--reset', 'Reset both days and copies to the plan default')
33
+ .option('--json', 'Output as JSON')
34
+ .action((opts) => run('Retention', async () => {
35
+ const hasSet = opts.days !== undefined || opts.count !== undefined;
36
+ if (opts.reset && hasSet) {
37
+ throw new Error('Use either --reset or --days/--count, not both.');
38
+ }
39
+ let data;
40
+ if (opts.reset) {
41
+ // null resets a field to the plan default; send both.
42
+ const res = await patch('/users/me/retention', { days: null, count: null });
43
+ data = res.data;
44
+ }
45
+ else if (hasSet) {
46
+ const body = {};
47
+ if (opts.days !== undefined)
48
+ body.days = parsePositiveInt(opts.days, '--days');
49
+ if (opts.count !== undefined)
50
+ body.count = parsePositiveInt(opts.count, '--count');
51
+ const res = await patch('/users/me/retention', body);
52
+ data = res.data;
53
+ }
54
+ else {
55
+ const res = await get('/users/me/retention');
56
+ data = res.data;
57
+ }
58
+ if (opts.json) {
59
+ console.log(JSON.stringify(data));
60
+ return;
61
+ }
62
+ printRetention(data, opts.reset === true || hasSet);
63
+ }));
64
+ //# sourceMappingURL=storage.js.map
@@ -12,7 +12,7 @@ import { Command } from 'commander';
12
12
  import { existsSync, rmSync, unlinkSync, readFileSync, writeFileSync } from 'fs';
13
13
  import { homedir } from 'os';
14
14
  import { join, resolve } from 'path';
15
- import { spawnSync } from 'child_process';
15
+ import { spawnSyncCommand } from '../platform.js';
16
16
  import { post } from '../api.js';
17
17
  import { getAuth } from '../auth.js';
18
18
  import { confirm, getAutoConfirm } from '../utils.js';
@@ -92,7 +92,7 @@ function removeServiceUnit() {
92
92
  // is fine if the service was never installed.
93
93
  let allOk = true;
94
94
  for (const argv of plan.disableCmds) {
95
- const r = spawnSync(argv[0], argv.slice(1), { stdio: 'ignore' });
95
+ const r = spawnSyncCommand(argv[0], argv.slice(1), { stdio: 'ignore' });
96
96
  if (r.status !== 0)
97
97
  allOk = false;
98
98
  }
@@ -32,7 +32,6 @@ export const uploadCommand = new Command('upload')
32
32
  .argument('<src>', 'Local source file or directory')
33
33
  .argument('[dest]', 'Destination path in the project (defaults to /)')
34
34
  .option('-r, --recursive', 'Upload a directory recursively')
35
- .option('--overwrite', 'Force a new version even if the file is byte-identical to the current version')
36
35
  .option('--mime <type>', 'Override the content-type (default: detect from extension)')
37
36
  .option('--concurrency <n>', `Parallel files (default ${UPLOAD_CONCURRENCY})`)
38
37
  .option('--dry-run', 'Print what would be uploaded; do not call the network')
@@ -85,7 +84,7 @@ export const uploadCommand = new Command('upload')
85
84
  return;
86
85
  }
87
86
  const concurrency = Math.max(1, parseInt(opts.concurrency ?? String(UPLOAD_CONCURRENCY), 10));
88
- const uploadOpts = { mime: opts.mime, overwrite: opts.overwrite };
87
+ const uploadOpts = { mime: opts.mime };
89
88
  let cursor = 0;
90
89
  let uploaded = 0, skipped = 0, resumed = 0, failed = 0;
91
90
  const workers = [];