@faable/faable 4.0.0 → 4.1.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.
@@ -1,9 +1,11 @@
1
1
  import { subject, requireSessionToken } from '../../api/auth_admin.js';
2
+ import { loadLiveCredentials } from '../../api/session.js';
2
3
  import { requireApi } from '../../api/context.js';
3
4
  import { resolveProjectRef, configuredProject, requireProject, SOURCE_LABEL } from '../../api/project.js';
4
5
  import { ContextStore } from '../../lib/ContextStore.js';
5
6
  import { fetch_page, print_json, table_lines, print, more_hint, list_options, json_option } from '../../lib/listing.js';
6
7
  import { log } from '../../log.js';
8
+ import { pickProject } from './picker.js';
7
9
 
8
10
  const project_list = {
9
11
  command: 'list',
@@ -43,24 +45,73 @@ const project_list = {
43
45
  }
44
46
  };
45
47
  const project_use = {
46
- command: 'use <project>',
47
- describe: 'Set the active project (id, name or slug)',
48
+ command: 'use [project]',
49
+ describe: 'Set the active project — id, name or slug; none to pick from a list; - for the previous one',
48
50
  builder: yargs => yargs
49
- .positional('project', { type: 'string', demandOption: true })
50
- .example('$0 project use project_6a8ebd6160324d4631c12edc', 'By id')
51
+ .positional('project', { type: 'string' })
52
+ .example('$0 project use', 'Pick from your projects (type to filter)')
51
53
  .example('$0 project use "Faable Staff"', 'By name')
54
+ .example('$0 project use project_6a8ebd6160324d4631c12edc', 'By id')
55
+ .example('$0 project use -', 'Back to the previous project')
52
56
  .showHelpOnFail(false),
53
57
  handler: async (args) => {
54
58
  const { api } = await requireApi();
55
- const id = await resolveProjectRef(api, args.project);
59
+ const store = new ContextStore();
60
+ const ctx = await store.load();
61
+ // yargs never hands over a lone `-` as the positional (it parses it as a
62
+ // flag-like `true`), so read it off the raw arguments.
63
+ const previous = args.project === '-' || process.argv.slice(2).includes('-');
64
+ const ref = typeof args.project === 'string' ? args.project : undefined;
65
+ let id;
66
+ if (previous) {
67
+ if (!ctx.previous)
68
+ throw new Error('No previous project to go back to.');
69
+ id = ctx.previous.id;
70
+ }
71
+ else if (ref) {
72
+ id = await resolveProjectRef(api, ref);
73
+ }
74
+ else {
75
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
76
+ throw new Error('Name the project: faable project use <id|name> (the picker needs a terminal).');
77
+ }
78
+ const token = process.env.FAABLE_TOKEN || (await loadLiveCredentials())?.token;
79
+ id = await pickProject(api, {
80
+ user_id: token ? subject(token) : undefined,
81
+ active: ctx.project
82
+ });
83
+ if (!id)
84
+ return log.info('Cancelled; the active project is unchanged.');
85
+ }
56
86
  // Fetching it is the membership check: the api 404s a project you can't see.
57
87
  const project = await api.getProject(id);
58
- await new ContextStore().update(ctx => ({ ...ctx, project: project.id }));
88
+ await store.update(c => ({
89
+ ...c,
90
+ project: project.id,
91
+ project_name: project.name,
92
+ // Re-selecting the active one keeps the real previous one for `use -`.
93
+ previous: c.project && c.project !== project.id
94
+ ? { id: c.project, name: c.project_name }
95
+ : c.previous
96
+ }));
59
97
  log.info(`✅ Active project: ${project.name} (${project.id})`);
60
98
  }
61
99
  };
100
+ const project_clear = {
101
+ command: 'clear',
102
+ describe: 'Forget the active project',
103
+ builder: yargs => yargs.showHelpOnFail(false),
104
+ handler: async () => {
105
+ await new ContextStore().update(c => {
106
+ const { project, project_name, ...rest } = c;
107
+ return project ? { ...rest, previous: { id: project, name: project_name } } : rest;
108
+ });
109
+ log.info('Active project cleared. Commands now need --project, FAABLE_PROJECT or faable project use.');
110
+ }
111
+ };
62
112
  const project_current = {
63
- command: 'current',
113
+ // Also what a bare `faable project` shows.
114
+ command: ['current', '$0'],
64
115
  describe: 'Show the active project and where it comes from',
65
116
  builder: yargs => json_option(yargs).showHelpOnFail(false),
66
117
  handler: async (args) => {
@@ -82,12 +133,12 @@ const project_current = {
82
133
  };
83
134
  const project = {
84
135
  command: 'project',
85
- describe: 'Choose the project the CLI acts on (list, use, current)',
136
+ describe: 'Show or change the project the CLI acts on (list, use, clear)',
86
137
  builder: yargs => yargs
87
138
  .command(project_list)
88
139
  .command(project_use)
140
+ .command(project_clear)
89
141
  .command(project_current)
90
- .demandCommand(1)
91
142
  .showHelpOnFail(false),
92
143
  handler: () => { }
93
144
  };
@@ -0,0 +1,56 @@
1
+ import prompts from 'prompts';
2
+
3
+ const pickCandidates = async (api, user_id) => {
4
+ const page = await api.listProjects({ pageSize: 200 });
5
+ if (!page.next)
6
+ return { projects: page.results, search_remote: false };
7
+ const mine = user_id
8
+ ? (await api.listProjects({ pageSize: 200, user_id })).results
9
+ : [];
10
+ return { projects: mine, search_remote: true };
11
+ };
12
+ const matches = (p, input) => {
13
+ const q = input.trim().toLowerCase();
14
+ if (!q)
15
+ return true;
16
+ return [p.name, p.slug, p.id].some(v => v?.toLowerCase().includes(q));
17
+ };
18
+ const title = (p, active) => `${p.id === active ? '● ' : ' '}${p.name} ${p.slug ?? p.id}`;
19
+ const pickProject = async (api, { user_id, active }) => {
20
+ const { projects, search_remote } = await pickCandidates(api, user_id);
21
+ if (projects.length === 0 && !search_remote) {
22
+ throw new Error('You have no projects yet. Create one in the dashboard (https://dashboard.faable.com).');
23
+ }
24
+ const as_choice = (p) => ({
25
+ title: title(p, active),
26
+ value: p.id
27
+ });
28
+ // The active project first: Enter keeps it, and it's where you look.
29
+ const ordered = [
30
+ ...projects.filter(p => p.id === active),
31
+ ...projects.filter(p => p.id !== active)
32
+ ];
33
+ const { project } = await prompts({
34
+ type: 'autocomplete',
35
+ name: 'project',
36
+ message: search_remote
37
+ ? 'Project (yours listed; type to search all)'
38
+ : 'Project (type to filter)',
39
+ limit: 15,
40
+ choices: ordered.map(as_choice),
41
+ suggest: async (input) => {
42
+ const local = ordered.filter(p => matches(p, input));
43
+ if (!search_remote || input.trim().length < 2)
44
+ return local.map(as_choice);
45
+ const remote = await api
46
+ .listProjects({ q: input.trim(), pageSize: 20 })
47
+ .then(r => r.results)
48
+ .catch(() => []);
49
+ const seen = new Set(local.map(p => p.id));
50
+ return [...local, ...remote.filter(p => !seen.has(p.id))].map(as_choice);
51
+ }
52
+ });
53
+ return project;
54
+ };
55
+
56
+ export { matches, pickCandidates, pickProject };
package/dist/index.js CHANGED
@@ -10,10 +10,23 @@ import { upgrade } from './commands/upgrade/index.js';
10
10
  import { whoami } from './commands/whoami/index.js';
11
11
  import { version } from './config.js';
12
12
  import { Configuration } from './lib/Configuration.js';
13
- import { setProjectFlag } from './api/project.js';
13
+ import { setProjectFlag, configuredProject } from './api/project.js';
14
+ import { ContextStore } from './lib/ContextStore.js';
14
15
  import { notifyIfUpdateAvailable } from './lib/UpdateChecker.js';
15
16
  import { log } from './log.js';
16
17
 
18
+ // Where the command acts, on the banner — read locally, never a request.
19
+ // The flag and the env show as typed; the stored one by the name it had when
20
+ // it was chosen.
21
+ const project_badge = async () => {
22
+ const configured = await configuredProject().catch(() => undefined);
23
+ if (!configured)
24
+ return '';
25
+ if (configured.source !== 'config')
26
+ return ` · project ${configured.ref}`;
27
+ const { project_name } = await new ContextStore().load();
28
+ return ` · project ${project_name ?? configured.ref}`;
29
+ };
17
30
  const wants_json = () => process.argv.includes('--json');
18
31
  // yargs re-runs before-validation middlewares once per nested command level
19
32
  // (`deploy secrets` = 2 runs), so keep the banner and update check to one.
@@ -26,6 +39,8 @@ yg.scriptName('faable')
26
39
  if (banner_shown)
27
40
  return;
28
41
  banner_shown = true;
42
+ // This middleware runs before the one below that records --project.
43
+ setProjectFlag(argv.project);
29
44
  // --json mode is for piping: keep stdout machine-clean (no banner, no
30
45
  // update-check notice). Same for `auth users export` without a file,
31
46
  // whose stdout IS the export.
@@ -33,7 +48,7 @@ yg.scriptName('faable')
33
48
  // Nor when nobody is watching stderr (a script, the MCP server).
34
49
  if (argv.json || exports_to_stdout || !process.stderr.isTTY)
35
50
  return;
36
- log.info(`Faable CLI ${version}`);
51
+ log.info(`Faable CLI ${version}${await project_badge()}`);
37
52
  // `upgrade` does its own (forced) check
38
53
  if (argv._[0] !== 'upgrade') {
39
54
  await notifyIfUpdateAvailable(version);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",